dataiku-sdk 3.9.0 → 4.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/README.md +3 -3
- package/dist/build-metadata.json +1 -1
- package/dist/packages/types/src/index.d.ts +4 -0
- package/dist/packages/types/src/index.js +4 -0
- package/dist/src/cli/coerce.d.ts +6 -0
- package/dist/src/cli/coerce.js +9 -0
- package/dist/src/cli/commands/app.js +2 -2
- package/dist/src/cli/commands/code-env.js +5 -6
- package/dist/src/cli/commands/code.js +16 -2
- package/dist/src/cli/commands/dashboard.js +1 -2
- package/dist/src/cli/commands/flow-zone.js +1 -1
- package/dist/src/cli/commands/job.js +67 -16
- package/dist/src/cli/commands/plugin.js +7 -7
- package/dist/src/cli/commands/project-library.js +2 -2
- package/dist/src/cli/commands/project.js +21 -4
- package/dist/src/cli/commands/recipe.js +11 -19
- package/dist/src/cli/commands/scenario.js +2 -4
- package/dist/src/cli/contract.js +27 -5
- package/dist/src/cli/env.js +2 -3
- package/dist/src/cli/helpers/app-permissions.d.ts +1 -1
- package/dist/src/cli/helpers/app-permissions.js +2 -2
- package/dist/src/cli/helpers/cleanup.d.ts +2 -0
- package/dist/src/cli/helpers/cleanup.js +122 -8
- package/dist/src/cli/helpers/flow-zone.d.ts +1 -1
- package/dist/src/cli/helpers/flow-zone.js +3 -4
- package/dist/src/cli/list-output.d.ts +5 -0
- package/dist/src/cli/list-output.js +11 -2
- package/dist/src/cli/list-shapes.d.ts +6 -0
- package/dist/src/cli/list-shapes.js +7 -1
- package/dist/src/cli/output-schemas.d.ts +1 -1
- package/dist/src/cli/output-schemas.js +1 -1
- package/dist/src/cli/output.js +7 -0
- package/dist/src/cli/version.d.ts +2 -2
- package/dist/src/cli/version.js +4 -5
- package/dist/src/cli.js +66 -17
- package/dist/src/client.d.ts +14 -2
- package/dist/src/client.js +18 -10
- package/dist/src/generated/action-output-schemas.json +18 -0
- package/dist/src/index.d.ts +1 -1
- package/dist/src/resources/datasets.d.ts +11 -1
- package/dist/src/resources/datasets.js +91 -30
- package/dist/src/resources/jobs.d.ts +35 -5
- package/dist/src/resources/jobs.js +68 -41
- package/dist/src/resources/projects.js +2 -2
- package/dist/src/resources/recipes.d.ts +12 -1
- package/dist/src/resources/recipes.js +39 -12
- package/dist/src/resources/scenarios.d.ts +8 -1
- package/dist/src/resources/scenarios.js +51 -12
- package/dist/src/resources/wiki.js +3 -7
- package/dist/src/skill.js +1 -2
- package/dist/src/utils/project-archive.d.ts +25 -0
- package/dist/src/utils/project-archive.js +216 -5
- package/dist/src/utils/response-file.js +15 -8
- package/package.json +11 -12
- package/plugin.json +1 -1
- package/skills/dataiku-dss/SKILL.md +1 -1
package/dist/src/client.js
CHANGED
|
@@ -211,7 +211,13 @@ export class DataikuClient {
|
|
|
211
211
|
maxResponseBodyBytes;
|
|
212
212
|
retryMaxAttempts;
|
|
213
213
|
verbose;
|
|
214
|
-
|
|
214
|
+
/**
|
|
215
|
+
* Connection pool carrying this client's TLS settings (custom CA, disabled
|
|
216
|
+
* verification). A session never shares connections with plain `fetch` or
|
|
217
|
+
* another client, so a connection opened with relaxed TLS cannot serve a
|
|
218
|
+
* client that expects strict verification. Default-TLS clients use plain fetch.
|
|
219
|
+
*/
|
|
220
|
+
session;
|
|
215
221
|
onTrace;
|
|
216
222
|
onValidationWarning;
|
|
217
223
|
/* Resource namespaces — lazily initialized to break circular imports */
|
|
@@ -606,7 +612,8 @@ export class DataikuClient {
|
|
|
606
612
|
const rawMax = config?.retryMaxAttempts ?? DEFAULT_RETRY_MAX_ATTEMPTS;
|
|
607
613
|
this.retryMaxAttempts = Math.min(Math.max(1, rawMax), MAX_RETRY_ATTEMPTS_CAP);
|
|
608
614
|
this.verbose = config?.verbose === true;
|
|
609
|
-
|
|
615
|
+
const tls = config ? buildFetchTlsOptions(config) : undefined;
|
|
616
|
+
this.session = tls ? new Bun.FetchSession({ tls, }) : undefined;
|
|
610
617
|
this.onTrace = config?.onTrace ?? defaultTrace;
|
|
611
618
|
this.onValidationWarning = config?.onValidationWarning ?? defaultValidationWarning;
|
|
612
619
|
}
|
|
@@ -691,7 +698,8 @@ export class DataikuClient {
|
|
|
691
698
|
* When the response exceeds the cap, earlier bytes are discarded, only
|
|
692
699
|
* the tail is kept and `truncated` is true — so callers that need the end
|
|
693
700
|
* of a large document (e.g. job logs) stay bounded without losing the most
|
|
694
|
-
* recent content. The
|
|
701
|
+
* recent content. The request timeout applies per received chunk (a stall
|
|
702
|
+
* deadline), not to the whole transfer.
|
|
695
703
|
*/
|
|
696
704
|
async getTextTailLimited(path, maxBytes) {
|
|
697
705
|
const limit = Math.max(0, Math.floor(maxBytes));
|
|
@@ -1080,6 +1088,11 @@ export class DataikuClient {
|
|
|
1080
1088
|
* Returns `truncated: true` when bytes had to be dropped. Bytes that fall
|
|
1081
1089
|
* in the middle of a UTF-8 character are dropped from both ends so the
|
|
1082
1090
|
* decoded text never contains a replacement character.
|
|
1091
|
+
*
|
|
1092
|
+
* The request timeout applies per chunk (a stall deadline), like the
|
|
1093
|
+
* streaming path: a large body that keeps arriving is read to EOF however
|
|
1094
|
+
* long the whole transfer takes, while a body that stops sending for longer
|
|
1095
|
+
* than the timeout fails with the body-read timeout error.
|
|
1083
1096
|
*/
|
|
1084
1097
|
async readBodyTextTail(res, maxBytes) {
|
|
1085
1098
|
const limit = Math.max(0, Math.floor(maxBytes));
|
|
@@ -1090,13 +1103,9 @@ export class DataikuClient {
|
|
|
1090
1103
|
let writeOffset = 0;
|
|
1091
1104
|
let retainedBytes = 0;
|
|
1092
1105
|
let truncated = false;
|
|
1093
|
-
const startedAt = Date.now();
|
|
1094
1106
|
try {
|
|
1095
1107
|
while (true) {
|
|
1096
|
-
const
|
|
1097
|
-
if (remainingMs <= 0)
|
|
1098
|
-
throw buildBodyReadTimeoutError(this.requestTimeoutMs);
|
|
1099
|
-
const { done, value, } = await readChunkWithDeadline(reader, remainingMs, this.requestTimeoutMs);
|
|
1108
|
+
const { done, value, } = await readChunkWithDeadline(reader, this.requestTimeoutMs, this.requestTimeoutMs);
|
|
1100
1109
|
if (done)
|
|
1101
1110
|
break;
|
|
1102
1111
|
if (limit === 0) {
|
|
@@ -1187,9 +1196,8 @@ export class DataikuClient {
|
|
|
1187
1196
|
...init,
|
|
1188
1197
|
method,
|
|
1189
1198
|
signal: controller.signal,
|
|
1199
|
+
...(this.session ? { session: this.session, } : {}),
|
|
1190
1200
|
};
|
|
1191
|
-
if (this.tlsOptions)
|
|
1192
|
-
requestInit.tls = this.tlsOptions;
|
|
1193
1201
|
const res = await fetch(url, requestInit);
|
|
1194
1202
|
this.logTrace({
|
|
1195
1203
|
phase: "response",
|
|
@@ -1119,6 +1119,12 @@
|
|
|
1119
1119
|
"timedOut": {
|
|
1120
1120
|
"type": "boolean"
|
|
1121
1121
|
},
|
|
1122
|
+
"cancelled": {
|
|
1123
|
+
"type": "boolean"
|
|
1124
|
+
},
|
|
1125
|
+
"resume": {
|
|
1126
|
+
"type": "string"
|
|
1127
|
+
},
|
|
1122
1128
|
"progress": {
|
|
1123
1129
|
"type": "object",
|
|
1124
1130
|
"required": [
|
|
@@ -1187,6 +1193,12 @@
|
|
|
1187
1193
|
"timedOut": {
|
|
1188
1194
|
"type": "boolean"
|
|
1189
1195
|
},
|
|
1196
|
+
"cancelled": {
|
|
1197
|
+
"type": "boolean"
|
|
1198
|
+
},
|
|
1199
|
+
"resume": {
|
|
1200
|
+
"type": "string"
|
|
1201
|
+
},
|
|
1190
1202
|
"progress": {
|
|
1191
1203
|
"type": "object",
|
|
1192
1204
|
"required": [
|
|
@@ -1577,6 +1589,12 @@
|
|
|
1577
1589
|
"string",
|
|
1578
1590
|
"null"
|
|
1579
1591
|
]
|
|
1592
|
+
},
|
|
1593
|
+
"isDev": {
|
|
1594
|
+
"type": [
|
|
1595
|
+
"boolean",
|
|
1596
|
+
"null"
|
|
1597
|
+
]
|
|
1580
1598
|
}
|
|
1581
1599
|
}
|
|
1582
1600
|
}
|
package/dist/src/index.d.ts
CHANGED
|
@@ -22,7 +22,7 @@ export { FoldersResource, } from "./resources/folders.js";
|
|
|
22
22
|
export { type DssTask, FuturesResource, type FutureWaitOptions, } from "./resources/futures.js";
|
|
23
23
|
export { type DssGroup, type GroupCreateRequest, GroupsResource, } from "./resources/groups.js";
|
|
24
24
|
export { InsightsResource, } from "./resources/insights.js";
|
|
25
|
-
export { type JobBuildAndWaitOptions, type JobBuildOptions, type JobBuildTarget, type JobBuildTargetType, type JobLogFilter, type JobLogProgress, type JobLogSummary, type JobLogUnavailableReason, JobsResource, type JobWaitOutcome, parseJobLogProgress, } from "./resources/jobs.js";
|
|
25
|
+
export { type JobBuildAndWaitOptions, type JobBuildOptions, type JobBuildTarget, type JobBuildTargetType, type JobLogDetails, type JobLogFilter, type JobLogProgress, type JobLogSummary, type JobLogUnavailableReason, JobsResource, type JobWaitOutcome, parseJobLogProgress, } from "./resources/jobs.js";
|
|
26
26
|
export * from "./resources/knowledge-banks.js";
|
|
27
27
|
export * from "./resources/llms.js";
|
|
28
28
|
export { type MacroResultOptions, type MacroRunFailure, type MacroRunHandle, type MacroRunOptions, type MacroRunWaitedResult, MacrosResource, type MacroWaitOptions, } from "./resources/macros.js";
|
|
@@ -135,9 +135,19 @@ export declare class DatasetsResource extends BaseResource {
|
|
|
135
135
|
/**
|
|
136
136
|
* TSV data stream of at most `limit` rows. A column or partition selection
|
|
137
137
|
* uses the documented POST variant of the data endpoint (body `columns`,
|
|
138
|
-
* `partitions`, `format`, `sampling`); otherwise the GET endpoint.
|
|
138
|
+
* `partitions`, `format`, `sampling`); otherwise the GET endpoint. Both carry
|
|
139
|
+
* a client-generated `readSessionId` for {@link verifyStreamComplete}.
|
|
139
140
|
*/
|
|
140
141
|
private dataStream;
|
|
142
|
+
/**
|
|
143
|
+
* A data stream that ended normally may still be incomplete: DSS sends 200
|
|
144
|
+
* headers before reading the data, so a server-side read failure only cuts
|
|
145
|
+
* the body short. As dataikuapi does, ask DSS whether the read session
|
|
146
|
+
* finished; it answers non-2xx (DataikuError) when it did not. Only called
|
|
147
|
+
* after the body was read to its end — a stream the client cancelled at
|
|
148
|
+
* its row cap is not complete by design.
|
|
149
|
+
*/
|
|
150
|
+
private verifyStreamComplete;
|
|
141
151
|
/**
|
|
142
152
|
* Preview dataset rows as structured data: column names plus row arrays,
|
|
143
153
|
* mirroring the sql query shape ({ columns, rows, rowCount }). Streams TSV
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import * as nodePath from "node:path";
|
|
3
|
-
import { StringDecoder, } from "node:string_decoder";
|
|
4
3
|
import { ClientValidationError, DataikuError, } from "../errors.js";
|
|
5
4
|
import { DatasetDetailsSchema, DatasetSchemaSchema, DatasetSummaryArraySchema, } from "../schemas.js";
|
|
6
5
|
import { deepMerge, } from "../utils/deep-merge.js";
|
|
@@ -28,8 +27,44 @@ function formatLimitBytes(bytes) {
|
|
|
28
27
|
return `${bytes / (1024 * 1024)} MiB`;
|
|
29
28
|
return `${bytes} B`;
|
|
30
29
|
}
|
|
31
|
-
|
|
32
|
-
|
|
30
|
+
const TSV_ERROR_COLUMN_NAME_MAX = 40;
|
|
31
|
+
/**
|
|
32
|
+
* Describe the column whose cell is being read when a field/row budget trips:
|
|
33
|
+
* the header name once the header row has been parsed, otherwise the 0-based
|
|
34
|
+
* column index. Error summaries are bounded (~200 chars), so the name is
|
|
35
|
+
* shortened to keep the `--columns` hint inside the visible message.
|
|
36
|
+
*/
|
|
37
|
+
function describeTsvColumn(state) {
|
|
38
|
+
const index = state.currentRow.length;
|
|
39
|
+
const name = state.header?.[index];
|
|
40
|
+
if (name === undefined)
|
|
41
|
+
return `column index ${index}`;
|
|
42
|
+
const shown = name.length > TSV_ERROR_COLUMN_NAME_MAX
|
|
43
|
+
? `${name.slice(0, TSV_ERROR_COLUMN_NAME_MAX)}...`
|
|
44
|
+
: name;
|
|
45
|
+
return `column "${shown}"`;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* A client-side size budget, not a DSS or transport failure: retrying the same
|
|
49
|
+
* read cannot succeed, so this is a non-retryable input error whose fix is a
|
|
50
|
+
* narrower selection (--columns).
|
|
51
|
+
*/
|
|
52
|
+
function tsvLimitError(what, limit, state) {
|
|
53
|
+
const exceeded = `Dataset TSV ${what} exceeded ${formatLimitBytes(limit)}`;
|
|
54
|
+
const cellLimit = formatLimitBytes(state.limits.maxFieldBytes);
|
|
55
|
+
let message;
|
|
56
|
+
if (what === "field") {
|
|
57
|
+
message = `${exceeded} in ${describeTsvColumn(state)} (a single cell is limited to ${cellLimit}); exclude it with --columns.`;
|
|
58
|
+
}
|
|
59
|
+
else if (what === "row") {
|
|
60
|
+
message = `${exceeded} (a single cell is limited to ${cellLimit}); reached in ${describeTsvColumn(state)}. Exclude large columns with --columns.`;
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
message = `${exceeded}; streaming was aborted to guard against unbounded memory use.`;
|
|
64
|
+
}
|
|
65
|
+
return new ClientValidationError(message, "validation_failed", what === "response"
|
|
66
|
+
? "Narrow the read with --columns, --partitions, or a lower row limit."
|
|
67
|
+
: "Select fewer columns with --columns so the oversized cell is not read.");
|
|
33
68
|
}
|
|
34
69
|
/** Characters that evaluate a spreadsheet cell as a formula. */
|
|
35
70
|
const FORMULA_SIGILS = "=+-@";
|
|
@@ -75,6 +110,7 @@ function createTsvStreamState(limits = {
|
|
|
75
110
|
return {
|
|
76
111
|
currentField: "",
|
|
77
112
|
currentRow: [],
|
|
113
|
+
header: undefined,
|
|
78
114
|
inQuotes: false,
|
|
79
115
|
pendingQuoteInQuotes: false,
|
|
80
116
|
fieldBytes: 0,
|
|
@@ -91,10 +127,10 @@ function appendFieldText(state, text) {
|
|
|
91
127
|
state.fieldBytes += bytes;
|
|
92
128
|
state.rowBytes += bytes;
|
|
93
129
|
if (state.fieldBytes > state.limits.maxFieldBytes) {
|
|
94
|
-
throw tsvLimitError("field", state.limits.maxFieldBytes);
|
|
130
|
+
throw tsvLimitError("field", state.limits.maxFieldBytes, state);
|
|
95
131
|
}
|
|
96
132
|
if (state.rowBytes > state.limits.maxRowBytes) {
|
|
97
|
-
throw tsvLimitError("row", state.limits.maxRowBytes);
|
|
133
|
+
throw tsvLimitError("row", state.limits.maxRowBytes, state);
|
|
98
134
|
}
|
|
99
135
|
state.currentField += text;
|
|
100
136
|
}
|
|
@@ -105,11 +141,20 @@ function endField(state) {
|
|
|
105
141
|
state.fieldBytes = 0;
|
|
106
142
|
state.rowBytes += 1; // count the delimiter byte
|
|
107
143
|
}
|
|
144
|
+
/** Hand a completed row to `onRow`, remembering the first non-blank row as the header. */
|
|
145
|
+
function emitRow(state, onRow) {
|
|
146
|
+
const row = state.currentRow;
|
|
147
|
+
state.currentRow = [];
|
|
148
|
+
state.rowBytes = 0;
|
|
149
|
+
if (state.header === undefined && !isBlankRow(row))
|
|
150
|
+
state.header = row;
|
|
151
|
+
onRow(row);
|
|
152
|
+
}
|
|
108
153
|
function consumeTsvChunk(text, state, onRow) {
|
|
109
154
|
// Response budget: bound total decoded bytes regardless of row structure.
|
|
110
155
|
state.responseBytes += Buffer.byteLength(text, "utf-8");
|
|
111
156
|
if (state.responseBytes > state.limits.maxResponseBytes) {
|
|
112
|
-
throw tsvLimitError("response", state.limits.maxResponseBytes);
|
|
157
|
+
throw tsvLimitError("response", state.limits.maxResponseBytes, state);
|
|
113
158
|
}
|
|
114
159
|
let i = 0;
|
|
115
160
|
if (state.pendingQuoteInQuotes) {
|
|
@@ -193,10 +238,7 @@ function consumeTsvChunk(text, state, onRow) {
|
|
|
193
238
|
if (ch === "\n") {
|
|
194
239
|
endField(state);
|
|
195
240
|
i = special + 1;
|
|
196
|
-
|
|
197
|
-
state.currentRow = [];
|
|
198
|
-
state.rowBytes = 0;
|
|
199
|
-
onRow(row);
|
|
241
|
+
emitRow(state, onRow);
|
|
200
242
|
continue;
|
|
201
243
|
}
|
|
202
244
|
// "\r" — carriage return: skipped, as in the original parser.
|
|
@@ -213,10 +255,7 @@ function flushTsvStream(state, onRow) {
|
|
|
213
255
|
state.currentRow.push(state.currentField);
|
|
214
256
|
state.currentField = "";
|
|
215
257
|
state.fieldBytes = 0;
|
|
216
|
-
|
|
217
|
-
state.currentRow = [];
|
|
218
|
-
state.rowBytes = 0;
|
|
219
|
-
onRow(row);
|
|
258
|
+
emitRow(state, onRow);
|
|
220
259
|
}
|
|
221
260
|
function rowToCsv(row, spreadsheetSafe) {
|
|
222
261
|
return row.map((field) => csvEscape(field, spreadsheetSafe)).join(",");
|
|
@@ -264,7 +303,7 @@ async function readChunkWithTimeout(reader, remainingMs, timeoutMs, action) {
|
|
|
264
303
|
}
|
|
265
304
|
async function collectPreviewRows(body, maxDataRows, timeoutMs, onHeader) {
|
|
266
305
|
const state = createTsvStreamState();
|
|
267
|
-
const decoder = new
|
|
306
|
+
const decoder = new TextDecoder("utf-8", { ignoreBOM: true, });
|
|
268
307
|
let columns;
|
|
269
308
|
const rows = [];
|
|
270
309
|
let done = false;
|
|
@@ -304,10 +343,10 @@ async function collectPreviewRows(body, maxDataRows, timeoutMs, onHeader) {
|
|
|
304
343
|
const result = await readChunkWithTimeout(reader, remainingMs, timeoutMs, "preview");
|
|
305
344
|
if (result.done)
|
|
306
345
|
break;
|
|
307
|
-
consumeTsvChunk(decoder.
|
|
346
|
+
consumeTsvChunk(decoder.decode(result.value, { stream: true, }), state, handleRow);
|
|
308
347
|
}
|
|
309
348
|
if (!done) {
|
|
310
|
-
const tail = decoder.
|
|
349
|
+
const tail = decoder.decode();
|
|
311
350
|
if (tail !== "")
|
|
312
351
|
consumeTsvChunk(tail, state, handleRow);
|
|
313
352
|
flushTsvStream(state, handleRow);
|
|
@@ -332,7 +371,7 @@ async function collectPreviewRows(body, maxDataRows, timeoutMs, onHeader) {
|
|
|
332
371
|
*/
|
|
333
372
|
async function collectTsvRowCount(body, stopAfter, timeoutMs) {
|
|
334
373
|
const state = createTsvStreamState();
|
|
335
|
-
const decoder = new
|
|
374
|
+
const decoder = new TextDecoder("utf-8", { ignoreBOM: true, });
|
|
336
375
|
let headerSeen = false;
|
|
337
376
|
let count = 0;
|
|
338
377
|
let bounded = false;
|
|
@@ -373,10 +412,10 @@ async function collectTsvRowCount(body, stopAfter, timeoutMs) {
|
|
|
373
412
|
const result = await readChunkWithTimeout(reader, remainingMs, timeoutMs, "row count assertion");
|
|
374
413
|
if (result.done)
|
|
375
414
|
break;
|
|
376
|
-
consumeTsvChunk(decoder.
|
|
415
|
+
consumeTsvChunk(decoder.decode(result.value, { stream: true, }), state, handleRow);
|
|
377
416
|
}
|
|
378
417
|
if (!bounded) {
|
|
379
|
-
const tail = decoder.
|
|
418
|
+
const tail = decoder.decode();
|
|
380
419
|
if (tail !== "")
|
|
381
420
|
consumeTsvChunk(tail, state, handleRow);
|
|
382
421
|
flushTsvStream(state, handleRow);
|
|
@@ -402,7 +441,7 @@ async function collectTsvRowCount(body, stopAfter, timeoutMs) {
|
|
|
402
441
|
const CSV_BATCH_BYTES = 64 * 1024;
|
|
403
442
|
function tsvToCsvTransform(maxDataRows, stats, onHeader, spreadsheetSafe = true) {
|
|
404
443
|
const state = createTsvStreamState();
|
|
405
|
-
const decoder = new
|
|
444
|
+
const decoder = new TextDecoder("utf-8", { ignoreBOM: true, });
|
|
406
445
|
const encoder = new TextEncoder();
|
|
407
446
|
const maxRows = Math.max(1, maxDataRows);
|
|
408
447
|
let headerSeen = false;
|
|
@@ -454,7 +493,7 @@ function tsvToCsvTransform(maxDataRows, stats, onHeader, spreadsheetSafe = true)
|
|
|
454
493
|
// which cancels the response and makes download() remove the partial file.
|
|
455
494
|
return new TransformStream({
|
|
456
495
|
transform(chunk, controller) {
|
|
457
|
-
consumeTsvChunk(decoder.
|
|
496
|
+
consumeTsvChunk(decoder.decode(chunk, { stream: true, }), state, (row) => handleRow(row, (line) => queueLine(controller, line)));
|
|
458
497
|
if (done) {
|
|
459
498
|
// Truncation reached: emit the batch queued so far, end the output,
|
|
460
499
|
// and cancel the rest of the response.
|
|
@@ -463,7 +502,7 @@ function tsvToCsvTransform(maxDataRows, stats, onHeader, spreadsheetSafe = true)
|
|
|
463
502
|
}
|
|
464
503
|
},
|
|
465
504
|
flush(controller) {
|
|
466
|
-
const tail = decoder.
|
|
505
|
+
const tail = decoder.decode();
|
|
467
506
|
if (tail !== "") {
|
|
468
507
|
consumeTsvChunk(tail, state, (row) => handleRow(row, (line) => queueLine(controller, line)));
|
|
469
508
|
}
|
|
@@ -660,19 +699,35 @@ export class DatasetsResource extends BaseResource {
|
|
|
660
699
|
/**
|
|
661
700
|
* TSV data stream of at most `limit` rows. A column or partition selection
|
|
662
701
|
* uses the documented POST variant of the data endpoint (body `columns`,
|
|
663
|
-
* `partitions`, `format`, `sampling`); otherwise the GET endpoint.
|
|
702
|
+
* `partitions`, `format`, `sampling`); otherwise the GET endpoint. Both carry
|
|
703
|
+
* a client-generated `readSessionId` for {@link verifyStreamComplete}.
|
|
664
704
|
*/
|
|
665
705
|
async dataStream(datasetName, limit, projectKey, selection) {
|
|
706
|
+
const readSessionId = crypto.randomUUID();
|
|
666
707
|
const path = `/public/api/projects/${this.enc(projectKey)}/datasets/${encodeURIComponent(datasetName)}/data/`;
|
|
667
708
|
if (!selection?.columns?.length && !selection?.partitions) {
|
|
668
|
-
|
|
709
|
+
const res = await this.client.stream(`${path}?format=tsv-excel-header&limit=${limit}&readSessionId=${readSessionId}`);
|
|
710
|
+
return { res, readSessionId, };
|
|
669
711
|
}
|
|
670
|
-
|
|
712
|
+
const res = await this.client.postStream(path, {
|
|
671
713
|
format: "tsv-excel-header",
|
|
672
714
|
...(selection.columns?.length ? { columns: selection.columns, } : {}),
|
|
673
715
|
...(selection.partitions ? { partitions: selection.partitions, } : {}),
|
|
674
716
|
sampling: { samplingMethod: "HEAD_SEQUENTIAL", maxRecords: limit, },
|
|
717
|
+
readSessionId,
|
|
675
718
|
});
|
|
719
|
+
return { res, readSessionId, };
|
|
720
|
+
}
|
|
721
|
+
/**
|
|
722
|
+
* A data stream that ended normally may still be incomplete: DSS sends 200
|
|
723
|
+
* headers before reading the data, so a server-side read failure only cuts
|
|
724
|
+
* the body short. As dataikuapi does, ask DSS whether the read session
|
|
725
|
+
* finished; it answers non-2xx (DataikuError) when it did not. Only called
|
|
726
|
+
* after the body was read to its end — a stream the client cancelled at
|
|
727
|
+
* its row cap is not complete by design.
|
|
728
|
+
*/
|
|
729
|
+
async verifyStreamComplete(datasetName, projectKey, readSessionId) {
|
|
730
|
+
await this.client.getTextLimited(`/public/api/projects/${this.enc(projectKey)}/datasets/${encodeURIComponent(datasetName)}/finish-streaming/?readSessionId=${readSessionId}`, 64 * 1024);
|
|
676
731
|
}
|
|
677
732
|
/**
|
|
678
733
|
* Preview dataset rows as structured data: column names plus row arrays,
|
|
@@ -691,7 +746,7 @@ export class DatasetsResource extends BaseResource {
|
|
|
691
746
|
const timeoutMs = Math.max(1, opts?.timeoutMs ?? this.client.getRequestTimeoutMs());
|
|
692
747
|
// Probe one row past the cap so callers learn whether the dataset holds
|
|
693
748
|
// more rows than requested without materializing the full dataset.
|
|
694
|
-
const res = await this.dataStream(datasetName, maxRows + 1, opts?.projectKey, opts);
|
|
749
|
+
const { res, readSessionId, } = await this.dataStream(datasetName, maxRows + 1, opts?.projectKey, opts);
|
|
695
750
|
const onHeader = opts?.validateColumns
|
|
696
751
|
? (headerRow) => {
|
|
697
752
|
const warnings = validateStreamColumns(headerRow, opts.validateColumns);
|
|
@@ -704,6 +759,8 @@ export class DatasetsResource extends BaseResource {
|
|
|
704
759
|
return { columns: [], rows: [], rowCount: 0, truncated: false, limit: maxRows, };
|
|
705
760
|
}
|
|
706
761
|
const { columns, rows, truncated, } = await collectPreviewRows(res.body, maxRows, timeoutMs, onHeader);
|
|
762
|
+
if (!truncated)
|
|
763
|
+
await this.verifyStreamComplete(datasetName, opts?.projectKey, readSessionId);
|
|
707
764
|
return {
|
|
708
765
|
columns: columns.map((name) => ({ name, })),
|
|
709
766
|
rows,
|
|
@@ -730,12 +787,13 @@ export class DatasetsResource extends BaseResource {
|
|
|
730
787
|
const target = expected;
|
|
731
788
|
const probe = target + 1;
|
|
732
789
|
const timeoutMs = Math.max(1, this.client.getRequestTimeoutMs());
|
|
733
|
-
const
|
|
734
|
-
const res = await this.client.stream(`/public/api/projects/${this.enc(projectKey)}/datasets/${dsEnc}/data/?format=tsv-excel-header&limit=${probe}`);
|
|
790
|
+
const { res, readSessionId, } = await this.dataStream(datasetName, probe, projectKey, undefined);
|
|
735
791
|
if (!res.body) {
|
|
736
792
|
return { expected: target, count: 0, exact: true, satisfied: target === 0, };
|
|
737
793
|
}
|
|
738
794
|
const { count, bounded, } = await collectTsvRowCount(res.body, probe, timeoutMs);
|
|
795
|
+
if (!bounded)
|
|
796
|
+
await this.verifyStreamComplete(datasetName, projectKey, readSessionId);
|
|
739
797
|
return { expected: target, count, exact: !bounded, satisfied: !bounded && count === target, };
|
|
740
798
|
}
|
|
741
799
|
/** Get dataset metadata (tags, custom fields, checklists). */
|
|
@@ -873,7 +931,7 @@ export class DatasetsResource extends BaseResource {
|
|
|
873
931
|
*/
|
|
874
932
|
async download(datasetName, opts) {
|
|
875
933
|
const limit = Math.max(1, opts?.limit ?? 100_000);
|
|
876
|
-
const res = await this.dataStream(datasetName, limit + 1, opts?.projectKey, opts);
|
|
934
|
+
const { res, readSessionId, } = await this.dataStream(datasetName, limit + 1, opts?.projectKey, opts);
|
|
877
935
|
const safeDatasetName = sanitizeFileName(datasetName, "dataset");
|
|
878
936
|
const filePath = opts?.outputPath?.endsWith(".gz") || opts?.outputPath?.endsWith(".csv")
|
|
879
937
|
? nodePath.resolve(opts.outputPath)
|
|
@@ -895,6 +953,9 @@ export class DatasetsResource extends BaseResource {
|
|
|
895
953
|
try {
|
|
896
954
|
// Native streaming write with backpressure; no Node stream layer.
|
|
897
955
|
await Bun.write(filePath, new Response(csv));
|
|
956
|
+
if (!stats.truncated) {
|
|
957
|
+
await this.verifyStreamComplete(datasetName, opts?.projectKey, readSessionId);
|
|
958
|
+
}
|
|
898
959
|
}
|
|
899
960
|
catch (error) {
|
|
900
961
|
// A failed export (byte-limit overflow, network error) must never
|
|
@@ -9,6 +9,20 @@ import { BaseResource } from "./base.js";
|
|
|
9
9
|
*/
|
|
10
10
|
export type JobBuildTargetType = "DATASET" | "MANAGED_FOLDER" | "MODEL_EVALUATION_STORE";
|
|
11
11
|
export type JobLogFilter = "stdout" | "stderr" | "user" | "errors";
|
|
12
|
+
/** A retrieved job log plus what the byte-bounded download dropped or scanned. */
|
|
13
|
+
export interface JobLogDetails {
|
|
14
|
+
/** Redacted, filtered and line-limited log text (what {@link JobsResource.log} returns). */
|
|
15
|
+
text: string;
|
|
16
|
+
/**
|
|
17
|
+
* True when the log exceeded the byte cap and only its most recent bytes
|
|
18
|
+
* were retained: earlier lines were never scanned or filtered.
|
|
19
|
+
*/
|
|
20
|
+
truncated: boolean;
|
|
21
|
+
/** Lines in the retained log before filtering and the line limit. */
|
|
22
|
+
scannedLines: number;
|
|
23
|
+
/** Byte cap applied to the download (the most recent this-many bytes are kept). */
|
|
24
|
+
maxLogBytes: number;
|
|
25
|
+
}
|
|
12
26
|
export interface JobLogProgress {
|
|
13
27
|
lastProgressLine?: string;
|
|
14
28
|
doneLine?: string;
|
|
@@ -83,11 +97,14 @@ export declare class JobsResource extends BaseResource {
|
|
|
83
97
|
* Use `0` or `-1` to return the log without line truncation. Credential-like
|
|
84
98
|
* JSON pairs (`"jobTicketSecret":"..."`) are redacted.
|
|
85
99
|
*
|
|
86
|
-
* The download is byte-bounded
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
100
|
+
* The download is byte-bounded: at most `maxLogBytes` (default 10 MiB) of
|
|
101
|
+
* the *most recent* log output is retained, so a collaborator-influenced
|
|
102
|
+
* oversized log cannot exhaust client memory while the line limit
|
|
103
|
+
* previously only applied after a full unbounded download. The request
|
|
104
|
+
* timeout is a stall deadline (per received chunk), so a large log that
|
|
105
|
+
* keeps arriving is not cut off by a total-transfer budget. Logs up to the
|
|
106
|
+
* cap are returned in full. Use {@link logDetails} to learn whether bytes
|
|
107
|
+
* were dropped.
|
|
91
108
|
*/
|
|
92
109
|
log(jobId: string, opts?: {
|
|
93
110
|
activity?: string;
|
|
@@ -97,6 +114,19 @@ export declare class JobsResource extends BaseResource {
|
|
|
97
114
|
maxLogBytes?: number;
|
|
98
115
|
projectKey?: string;
|
|
99
116
|
}): Promise<string>;
|
|
117
|
+
/**
|
|
118
|
+
* Same retrieval as {@link log}, additionally reporting whether the
|
|
119
|
+
* byte-bounded download dropped early log bytes (`truncated`) and how many
|
|
120
|
+
* lines were scanned before filtering (`scannedLines`).
|
|
121
|
+
*/
|
|
122
|
+
logDetails(jobId: string, opts?: {
|
|
123
|
+
activity?: string;
|
|
124
|
+
logId?: string;
|
|
125
|
+
logFilter?: JobLogFilter;
|
|
126
|
+
maxLogLines?: number;
|
|
127
|
+
maxLogBytes?: number;
|
|
128
|
+
projectKey?: string;
|
|
129
|
+
}): Promise<JobLogDetails>;
|
|
100
130
|
logFromUrl(logUrl: string, opts?: {
|
|
101
131
|
maxLogLines?: number;
|
|
102
132
|
maxLogBytes?: number;
|
|
@@ -66,6 +66,12 @@ function jobBuildDefinition(targets, defaultProjectKey, opts) {
|
|
|
66
66
|
function jobLogLines(log) {
|
|
67
67
|
return log.split(/\r?\n/).map((line) => line.trimEnd());
|
|
68
68
|
}
|
|
69
|
+
function countLogLines(log) {
|
|
70
|
+
if (!log)
|
|
71
|
+
return 0;
|
|
72
|
+
const lines = jobLogLines(log);
|
|
73
|
+
return lines[lines.length - 1] === "" ? lines.length - 1 : lines.length;
|
|
74
|
+
}
|
|
69
75
|
/**
|
|
70
76
|
* DSS tags recipe subprocess output by reader thread: `[null-out-N]` carries
|
|
71
77
|
* the child's stdout and `[null-err-N]` its stderr. The legacy fallback is an
|
|
@@ -81,6 +87,13 @@ const LEGACY_STDERR_RECORD = /^\s*stderr:/i;
|
|
|
81
87
|
* continuation of that record (exception chain, JVM frames, ...).
|
|
82
88
|
*/
|
|
83
89
|
const DSS_RECORD_HEADER = /^\[\d{4}\/\d{2}\/\d{2}-\d{2}:\d{2}:\d{2}\.\d{3}\]/;
|
|
90
|
+
/**
|
|
91
|
+
* Job-level outcome line the job engine writes outside any record, e.g.
|
|
92
|
+
* `2026-10-12T09:13:22.189: Job is incompatible with build state: [...]` when
|
|
93
|
+
* another running job already builds the target. It carries no level word but
|
|
94
|
+
* is the only reason DSS gives for such a failure.
|
|
95
|
+
*/
|
|
96
|
+
const JOB_OUTCOME_LINE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}: \S/;
|
|
84
97
|
const ERROR_KEYWORDS = /\b(error|failed|failure|exception|traceback)\b/i;
|
|
85
98
|
/**
|
|
86
99
|
* Exception class names ending in the `Exception`/`Error` suffix (e.g.
|
|
@@ -149,7 +162,7 @@ function lineMatchesLogFilter(line, filter) {
|
|
|
149
162
|
case "stderr":
|
|
150
163
|
return normalized.includes("[null-err-") || LEGACY_STDERR_RECORD.test(line);
|
|
151
164
|
case "errors":
|
|
152
|
-
return isErrorContent(line);
|
|
165
|
+
return isErrorContent(line) || JOB_OUTCOME_LINE.test(line);
|
|
153
166
|
case "user":
|
|
154
167
|
return !/^\d{4}[-/]\d{2}[-/]\d{2}/.test(line)
|
|
155
168
|
&& !normalized.includes("backend-log")
|
|
@@ -195,6 +208,11 @@ function filterJobLogToErrors(lines) {
|
|
|
195
208
|
}
|
|
196
209
|
pyTraceback = false;
|
|
197
210
|
}
|
|
211
|
+
if (JOB_OUTCOME_LINE.test(line)) {
|
|
212
|
+
kept.push(line);
|
|
213
|
+
recordMatches = false;
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
198
216
|
if (isHeader) {
|
|
199
217
|
recordMatches = isErrorContent(stripJsonPayloads(line)) || PY_FILE_FRAME.test(message);
|
|
200
218
|
if (recordMatches && (PY_TRACEBACK_HEAD.test(message) || PY_FILE_FRAME.test(message))) {
|
|
@@ -322,22 +340,39 @@ export class JobsResource extends BaseResource {
|
|
|
322
340
|
* Use `0` or `-1` to return the log without line truncation. Credential-like
|
|
323
341
|
* JSON pairs (`"jobTicketSecret":"..."`) are redacted.
|
|
324
342
|
*
|
|
325
|
-
* The download is byte-bounded
|
|
326
|
-
*
|
|
327
|
-
*
|
|
328
|
-
*
|
|
329
|
-
*
|
|
343
|
+
* The download is byte-bounded: at most `maxLogBytes` (default 10 MiB) of
|
|
344
|
+
* the *most recent* log output is retained, so a collaborator-influenced
|
|
345
|
+
* oversized log cannot exhaust client memory while the line limit
|
|
346
|
+
* previously only applied after a full unbounded download. The request
|
|
347
|
+
* timeout is a stall deadline (per received chunk), so a large log that
|
|
348
|
+
* keeps arriving is not cut off by a total-transfer budget. Logs up to the
|
|
349
|
+
* cap are returned in full. Use {@link logDetails} to learn whether bytes
|
|
350
|
+
* were dropped.
|
|
330
351
|
*/
|
|
331
352
|
async log(jobId, opts) {
|
|
353
|
+
return (await this.logDetails(jobId, opts)).text;
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* Same retrieval as {@link log}, additionally reporting whether the
|
|
357
|
+
* byte-bounded download dropped early log bytes (`truncated`) and how many
|
|
358
|
+
* lines were scanned before filtering (`scannedLines`).
|
|
359
|
+
*/
|
|
360
|
+
async logDetails(jobId, opts) {
|
|
332
361
|
const jobEnc = encodeURIComponent(jobId);
|
|
333
362
|
const query = opts?.activity ? `?activity=${encodeURIComponent(opts.activity)}` : "";
|
|
334
363
|
// DSS cat-activity-log URLs require a browser session; API-key callers must use the public log endpoint.
|
|
335
364
|
const path = `/public/api/projects/${this.enc(opts?.projectKey)}/jobs/${jobEnc}/log/${query}`;
|
|
336
365
|
const maxLogBytes = Math.max(1, opts?.maxLogBytes ?? DEFAULT_MAX_LOG_BYTES);
|
|
337
|
-
const { text, } = await this.client.getTextTailLimited(path, maxLogBytes);
|
|
366
|
+
const { text, truncated, } = await this.client.getTextTailLimited(path, maxLogBytes);
|
|
338
367
|
// Single download choke point (logFromUrl and wait/summary go through here):
|
|
339
368
|
// DSS logs embed the job ticket secret in start_session JSON.
|
|
340
|
-
|
|
369
|
+
const redacted = redactCredentialPairs(text);
|
|
370
|
+
return {
|
|
371
|
+
text: limitJobLog(filterJobLog(redacted, opts?.logFilter), opts?.maxLogLines),
|
|
372
|
+
truncated,
|
|
373
|
+
scannedLines: countLogLines(redacted),
|
|
374
|
+
maxLogBytes,
|
|
375
|
+
};
|
|
341
376
|
}
|
|
342
377
|
async logFromUrl(logUrl, opts) {
|
|
343
378
|
const parsed = new URL(logUrl, "http://dss.local");
|
|
@@ -438,6 +473,28 @@ export class JobsResource extends BaseResource {
|
|
|
438
473
|
const startedAt = Date.now();
|
|
439
474
|
let pollCount = 0;
|
|
440
475
|
let lastJ;
|
|
476
|
+
// The wait only stops polling: the job keeps running in DSS and is never
|
|
477
|
+
// cancelled, so the result says so explicitly.
|
|
478
|
+
const timedOutResult = (last, elapsedMs) => {
|
|
479
|
+
const bs = last?.baseStatus ?? {};
|
|
480
|
+
const gs = last?.globalState ?? {};
|
|
481
|
+
return {
|
|
482
|
+
success: false,
|
|
483
|
+
jobId,
|
|
484
|
+
state: bs.state ?? "unknown",
|
|
485
|
+
type: bs.def?.type ?? "unknown",
|
|
486
|
+
elapsedMs,
|
|
487
|
+
pollCount,
|
|
488
|
+
timedOut: true,
|
|
489
|
+
cancelled: false,
|
|
490
|
+
progress: {
|
|
491
|
+
done: gs.done ?? 0,
|
|
492
|
+
failed: gs.failed ?? 0,
|
|
493
|
+
running: gs.running ?? 0,
|
|
494
|
+
total: gs.total ?? null,
|
|
495
|
+
},
|
|
496
|
+
};
|
|
497
|
+
};
|
|
441
498
|
while (true) {
|
|
442
499
|
const elapsedBeforeMs = Date.now() - startedAt;
|
|
443
500
|
// The first observation always happens, even when the budget is
|
|
@@ -445,24 +502,7 @@ export class JobsResource extends BaseResource {
|
|
|
445
502
|
// the loop reports the structured timeout from the last observed
|
|
446
503
|
// state instead of issuing a request the budget cannot cover.
|
|
447
504
|
if (lastJ !== undefined && elapsedBeforeMs >= timeout) {
|
|
448
|
-
|
|
449
|
-
const def = bs.def ?? {};
|
|
450
|
-
const gs = lastJ.globalState ?? {};
|
|
451
|
-
return {
|
|
452
|
-
success: false,
|
|
453
|
-
jobId,
|
|
454
|
-
state: bs.state ?? "unknown",
|
|
455
|
-
type: def.type ?? "unknown",
|
|
456
|
-
elapsedMs: elapsedBeforeMs,
|
|
457
|
-
pollCount,
|
|
458
|
-
timedOut: true,
|
|
459
|
-
progress: {
|
|
460
|
-
done: gs.done ?? 0,
|
|
461
|
-
failed: gs.failed ?? 0,
|
|
462
|
-
running: gs.running ?? 0,
|
|
463
|
-
total: gs.total ?? null,
|
|
464
|
-
},
|
|
465
|
-
};
|
|
505
|
+
return timedOutResult(lastJ, elapsedBeforeMs);
|
|
466
506
|
}
|
|
467
507
|
pollCount += 1;
|
|
468
508
|
// Requests issued while budget remains are bounded by the remaining
|
|
@@ -482,21 +522,8 @@ export class JobsResource extends BaseResource {
|
|
|
482
522
|
// error escape the loop.
|
|
483
523
|
if (!isRequestDeadlineError(error, startedAt + timeout))
|
|
484
524
|
throw error;
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
jobId,
|
|
488
|
-
state: "unknown",
|
|
489
|
-
type: "unknown",
|
|
490
|
-
elapsedMs: Date.now() - startedAt,
|
|
491
|
-
pollCount,
|
|
492
|
-
timedOut: true,
|
|
493
|
-
progress: {
|
|
494
|
-
done: 0,
|
|
495
|
-
failed: 0,
|
|
496
|
-
running: 0,
|
|
497
|
-
total: null,
|
|
498
|
-
},
|
|
499
|
-
};
|
|
525
|
+
// A deadline mid-poll keeps the last state a previous poll observed.
|
|
526
|
+
return timedOutResult(lastJ, Date.now() - startedAt);
|
|
500
527
|
}
|
|
501
528
|
lastJ = j;
|
|
502
529
|
const bs = j.baseStatus ?? {};
|