sparkforensics-mcp 0.2.3 → 0.2.4
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/bin/sparkforensics-mcp.mjs +11 -5
- package/package.json +3 -3
- package/vendor-core/impact-estimator.js +23 -3
- package/vendor-core/occupancy.js +8 -7
- package/vendor-core/parser-worker.js +31 -15
- package/vendor-core/plan-summary.js +1 -1
- package/vendor-core/stage-quantiles.js +53 -1
- package/vendor-core/types.js +5 -0
- package/vendor-core/zstd-worker-client.js +180 -0
- package/vendor-core/zstd-worker.js +103 -0
|
@@ -19,20 +19,26 @@ async function loadCreateMcpServer() {
|
|
|
19
19
|
return mod.createMcpServer;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
|
|
22
|
+
// Tool names come from the server the bin actually builds, so --help can't drift
|
|
23
|
+
// from the registered set. Building the server registers tools only: no transport,
|
|
24
|
+
// no I/O. _registeredTools is the SDK's registry; the MCP package test checks that
|
|
25
|
+
// this list matches what listTools reports.
|
|
26
|
+
function usage(toolNames) {
|
|
27
|
+
return `Usage: sparkforensics-mcp
|
|
23
28
|
|
|
24
29
|
Starts the SparkForensics MCP server, speaking the MCP protocol over
|
|
25
30
|
stdio. Point an MCP client (Claude Desktop, Claude Code, etc.) at this
|
|
26
|
-
command; it exposes
|
|
27
|
-
|
|
28
|
-
get_finding_evidence.
|
|
31
|
+
command; it exposes ${toolNames.length} tools for diagnosing Apache Spark event logs:
|
|
32
|
+
${toolNames.join(', ')}.
|
|
29
33
|
|
|
30
34
|
See https://github.com/shuffle-works/sparkforensics#readme for details.
|
|
31
35
|
`;
|
|
36
|
+
}
|
|
32
37
|
|
|
33
38
|
async function main() {
|
|
34
39
|
if (process.argv.includes('--help') || process.argv.includes('-h')) {
|
|
35
|
-
|
|
40
|
+
const createMcpServer = await loadCreateMcpServer();
|
|
41
|
+
process.stderr.write(usage(Object.keys(createMcpServer()._registeredTools)));
|
|
36
42
|
return;
|
|
37
43
|
}
|
|
38
44
|
const createMcpServer = await loadCreateMcpServer();
|
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sparkforensics-mcp",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.4",
|
|
4
4
|
"mcpName": "io.github.shuffle-works/sparkforensics-mcp",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
|
-
"description": "MCP server exposing Apache Spark event-log diagnostics
|
|
7
|
+
"description": "MCP server exposing Apache Spark event-log diagnostics over stdio.",
|
|
8
8
|
"repository": {
|
|
9
9
|
"type": "git",
|
|
10
10
|
"url": "git+https://github.com/shuffle-works/sparkforensics.git"
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
"performance"
|
|
24
24
|
],
|
|
25
25
|
"bin": {
|
|
26
|
-
"sparkforensics-mcp": "
|
|
26
|
+
"sparkforensics-mcp": "bin/sparkforensics-mcp.mjs"
|
|
27
27
|
},
|
|
28
28
|
"engines": {
|
|
29
29
|
"node": ">=18"
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
|
|
2
|
+
import { nsToMs } from './format-utils.js';
|
|
2
3
|
import {
|
|
3
4
|
computeOccupancy, estimateSingleStage, estimateMultiStage, tailRecoveryMs, tailRemovedWorkMs, stragglerFixLongestTaskMs,
|
|
4
5
|
|
|
@@ -82,6 +83,24 @@ const EXECUTOR_STARTUP_OVERHEAD_MS = 15000;
|
|
|
82
83
|
// Assumed re-read throughput, shared by cachingOpportunity and cacheUtilization.
|
|
83
84
|
const RE_READ_THROUGHPUT_BPS = 125_000_000;
|
|
84
85
|
|
|
86
|
+
// Below this share of executorRunTime spent on CPU, a stage's tasks were idle, waiting on something
|
|
87
|
+
// outside Spark: on 14 real logs (2026-09-23) every non-Python stage under 1% was a JDBC read, a
|
|
88
|
+
// file listing or a Delta log read, while file writes, which more partitions do parallelize,
|
|
89
|
+
// start at 2%.
|
|
90
|
+
const IDLE_CPU_SHARE_MAX = 0.01;
|
|
91
|
+
|
|
92
|
+
// True when the stage's tasks spent under IDLE_CPU_SHARE_MAX of their run time on CPU. False when
|
|
93
|
+
// the share can't be trusted: no CPU time recorded (older Spark logs omit the metric), or Python
|
|
94
|
+
// code run through PythonRDD, whose worker-process CPU executorCpuTime (the JVM task thread's)
|
|
95
|
+
// never counts (such stages read 0.1% on the same logs while computing).
|
|
96
|
+
function tasksMostlyIdle(stage ) {
|
|
97
|
+
const runMs = stage.executorRunTime ?? 0;
|
|
98
|
+
const cpuMs = nsToMs(stage.executorCpuTime ?? 0);
|
|
99
|
+
if (runMs <= 0 || cpuMs <= 0) return false;
|
|
100
|
+
if (/PythonRDD/.test(stage.name ?? '') || /org\.apache\.spark\.api\.python\./.test(stage.details ?? '')) return false;
|
|
101
|
+
return cpuMs / runMs < IDLE_CPU_SHARE_MAX;
|
|
102
|
+
}
|
|
103
|
+
|
|
85
104
|
// skew and straggler claim time off the stage's longest task itself, so the occupancy clip must
|
|
86
105
|
// not floor them at that same task (see estimateSingleStage). detectors.ts's clippedWasteMs gates
|
|
87
106
|
// both detectors on the same option so the firing floor and the displayed estimate agree.
|
|
@@ -332,14 +351,15 @@ function computeEstimateForFinding(
|
|
|
332
351
|
// is queueing no partition count recovers. Splitting partitions splits the longest task
|
|
333
352
|
// too, hence TAIL_CLAIM's post-fix floor. Unknown cluster size: no defensible figure.
|
|
334
353
|
if (totalCores <= 0) return costOnly('modeled');
|
|
335
|
-
// A stage that read no input and no shuffle
|
|
336
|
-
// 1-task count stage open 27 minutes on 5s of CPU
|
|
354
|
+
// A stage that read no input and no shuffle, its tasks idle waiting on an external system,
|
|
355
|
+
// gains nothing from more partitions (a 1-task JDBC count stage open 27 minutes on 5s of CPU
|
|
356
|
+
// was claimed 99% recoverable): claim 0. Stages that read bytes keep their claim.
|
|
337
357
|
const readBytes = (stage.inputBytes ?? 0) + (stage.shuffleReadBytes ?? 0);
|
|
338
358
|
const activeMs = typeof stage.taskActiveMs === 'number'
|
|
339
359
|
? stage.taskActiveMs
|
|
340
360
|
: Math.max(0, (stage.completedAt ?? 0) - (stage.submittedAt ?? 0));
|
|
341
361
|
const taskCount = stage.taskCount ?? 0;
|
|
342
|
-
const wasteMs = readBytes
|
|
362
|
+
const wasteMs = readBytes <= 0 && tasksMostlyIdle(stage) ? 0 : activeMs * Math.max(0, 1 - taskCount / totalCores);
|
|
343
363
|
return singleStageImpact(wasteMs, finding.stageId, stages, occupancy, 'modeled', { value: wasteMs, unit: 'ms' }, TAIL_CLAIM);
|
|
344
364
|
}
|
|
345
365
|
case 'partitionSizing': {
|
package/vendor-core/occupancy.js
CHANGED
|
@@ -152,18 +152,19 @@ const SERIAL_GATE_THRESHOLD = 0.999;
|
|
|
152
152
|
|
|
153
153
|
|
|
154
154
|
|
|
155
|
+
|
|
155
156
|
|
|
156
157
|
|
|
157
158
|
|
|
158
159
|
|
|
159
|
-
// Wall-clock a skew/straggler fix recovers
|
|
160
|
-
//
|
|
161
|
-
//
|
|
162
|
-
//
|
|
163
|
-
//
|
|
164
|
-
//
|
|
165
|
-
// peak on one real stage), which doubled the claim.
|
|
160
|
+
// Wall-clock a skew/straggler fix recovers: finalizeStage's task-level replay
|
|
161
|
+
// (tailReplayRecoveryMs, computeTailReplayRecoveryMs) when the stage carries it. Without it (a
|
|
162
|
+
// stage built by hand, as in detector tests) an estimate from the slowest task's excess over the
|
|
163
|
+
// median: a lone straggler costs that excess; a tail of many slow tasks costs its summed excess
|
|
164
|
+
// (stragglerExcessMs) spread over the slots the stage had (peakConcurrentTasks), and the replay
|
|
165
|
+
// recovers about the larger of the two.
|
|
166
166
|
export function tailRecoveryMs(stage , singleTaskExcessMs ) {
|
|
167
|
+
if (stage.tailReplayRecoveryMs != null) return stage.tailReplayRecoveryMs;
|
|
167
168
|
const excessMs = stage.stragglerExcessMs ?? 0;
|
|
168
169
|
const slots = stage.peakConcurrentTasks ?? 0;
|
|
169
170
|
if (excessMs <= 0 || slots <= 0) return singleTaskExcessMs;
|
|
@@ -5,6 +5,7 @@ import { createSnappyBlockDecoder } from './snappy-block.js';
|
|
|
5
5
|
import { createState, dispatchLine, buildChunkDecoder, emitParseCompletion, } from './event-handlers.js';
|
|
6
6
|
import { TASK_FIELD_NAMES } from './stage-quantiles.js';
|
|
7
7
|
import { runParseFromUrl, sniffCodec } from './shs-fetch.js';
|
|
8
|
+
import { createWorkerZstdDecoders } from './zstd-worker-client.js';
|
|
8
9
|
|
|
9
10
|
export {
|
|
10
11
|
buildChunkDecoder, createState, normalizeSparkProperties, parseSparkMemoryMB, extractResources,
|
|
@@ -61,9 +62,11 @@ const PROGRESS_EMIT_LINES = 300;
|
|
|
61
62
|
// without touching the vendored files (mirrors shs-fetch.ts's shim).
|
|
62
63
|
|
|
63
64
|
|
|
64
|
-
// A Node decoder
|
|
65
|
+
// A Node decoder, or the browser's decompress worker (zstd-worker-client.ts), may decompress off
|
|
66
|
+
// the calling thread: streamFile awaits each push, and calls cancel() when it abandons the stream.
|
|
65
67
|
|
|
66
68
|
|
|
69
|
+
|
|
67
70
|
|
|
68
71
|
|
|
69
72
|
// Stream one File's (possibly compressed) bytes through the codec dispatch,
|
|
@@ -89,7 +92,7 @@ export async function streamFile(
|
|
|
89
92
|
const gunzip = codec === 'gz' ? new (Gunzip )((inflated) => onChunk(inflated, currentPct)) : null;
|
|
90
93
|
const lz4 = codec === 'lz4' ? createLz4BlockDecoder((inflated) => onChunk(inflated, currentPct)) : null;
|
|
91
94
|
const onZstdChunk = (inflated ) => onChunk(inflated, currentPct);
|
|
92
|
-
const zstd
|
|
95
|
+
const zstd = codec !== 'zstd' ? null
|
|
93
96
|
: zstdDecoder ? zstdDecoder(onZstdChunk)
|
|
94
97
|
: new (ZstdDecompress )(onZstdChunk);
|
|
95
98
|
const snappy = codec === 'snappy' ? createSnappyBlockDecoder((inflated) => onChunk(inflated, currentPct)) : null;
|
|
@@ -101,17 +104,22 @@ export async function streamFile(
|
|
|
101
104
|
const stepSize = Math.max(1, Math.min(chunkSize, Math.ceil(file.size / MIN_PROGRESS_STEPS)));
|
|
102
105
|
|
|
103
106
|
let offset = 0;
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
107
|
+
try {
|
|
108
|
+
while (offset < file.size) {
|
|
109
|
+
const start = offset;
|
|
110
|
+
const slice = new Uint8Array(await file.slice(start, start + stepSize).arrayBuffer());
|
|
111
|
+
offset += stepSize;
|
|
112
|
+
const final = offset >= file.size;
|
|
113
|
+
currentPct = start / file.size;
|
|
114
|
+
if (gunzip) gunzip.push(slice, final);
|
|
115
|
+
else if (lz4) lz4.push(slice);
|
|
116
|
+
else if (zstd) await zstd.push(slice, final);
|
|
117
|
+
else if (snappy) snappy.push(slice);
|
|
118
|
+
else onChunk(slice, currentPct);
|
|
119
|
+
}
|
|
120
|
+
} catch (e) {
|
|
121
|
+
zstd?.cancel?.();
|
|
122
|
+
throw e;
|
|
115
123
|
}
|
|
116
124
|
if (lz4) lz4.end();
|
|
117
125
|
if (snappy) snappy.end();
|
|
@@ -232,17 +240,25 @@ const isWorker = typeof WorkerGlobalScope !== 'undefined' && self instanceof Wor
|
|
|
232
240
|
|
|
233
241
|
if (isWorker) {
|
|
234
242
|
let workerState = null;
|
|
243
|
+
// Dropped zstd files decompress in a second worker, overlapping with parsing here. The
|
|
244
|
+
// `new Worker(new URL(...))` stays inline for Vite's worker detection (see ingest.ts). The SHS
|
|
245
|
+
// path (runParseFromUrl) decodes whole zip entries synchronously and keeps in-thread fzstd.
|
|
246
|
+
const zstdDecoder = createWorkerZstdDecoders(
|
|
247
|
+
() => new Worker(new URL('./zstd-worker.js', import.meta.url), { type: 'module' }),
|
|
248
|
+
(onChunk) => new (ZstdDecompress )(onChunk),
|
|
249
|
+
{ onFallback: (reason) => console.warn(`zstd: decompressing on the parse worker: ${reason}`) },
|
|
250
|
+
);
|
|
235
251
|
|
|
236
252
|
self.onmessage = async ({ data } ) => {
|
|
237
253
|
if (data.type === 'parse') {
|
|
238
254
|
workerState = createState();
|
|
239
|
-
await runParse(data.file, workerState);
|
|
255
|
+
await runParse(data.file, workerState, { zstdDecoder });
|
|
240
256
|
} else if (data.type === 'parseFromUrl') {
|
|
241
257
|
workerState = createState();
|
|
242
258
|
await runParseFromUrl(data.request, workerState);
|
|
243
259
|
} else if (data.type === 'parseFiles') {
|
|
244
260
|
workerState = createState();
|
|
245
|
-
await runParseFiles(data.files, workerState);
|
|
261
|
+
await runParseFiles(data.files, workerState, { zstdDecoder });
|
|
246
262
|
} else if (data.type === 'getTaskData') {
|
|
247
263
|
const { stageId, reqId } = data;
|
|
248
264
|
const stored = workerState?.taskStore.get(stageId) ?? new Float64Array(0);
|
|
@@ -45,7 +45,7 @@ function pushLongFilterWarning(result , len ) {
|
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
// Stable relation-identity key for a scan node: "<format>:<relation>" (e.g.
|
|
48
|
-
// "delta:mx.
|
|
48
|
+
// "delta:mx.store_map", "parquet:warehouse.sales", "jdbc:dw.dim_product")
|
|
49
49
|
// or null when the node is internal Delta metadata / a non-scan / un-nameable.
|
|
50
50
|
// The single source of truth for scan identity, shared by `visitScan` (summary)
|
|
51
51
|
// and the `cachingOpportunity` detector so the regexes live in one place.
|
|
@@ -150,6 +150,11 @@ export function finalizeStage(
|
|
|
150
150
|
}
|
|
151
151
|
}
|
|
152
152
|
|
|
153
|
+
const peakConcurrentTasks = computePeakConcurrentTasks(arr);
|
|
154
|
+
const tailReplayRecoveryMs = stragglerCount > 0
|
|
155
|
+
? computeTailReplayRecoveryMs(arr, p50, peakConcurrentTasks)
|
|
156
|
+
: 0; // no task over 4x P50: both replays schedule the same durations
|
|
157
|
+
|
|
153
158
|
const hostStatsArr = [...hostStats.entries()].map(
|
|
154
159
|
([host, s]) => ({ host, taskCount: s.taskCount, totalDuration: s.totalDuration })
|
|
155
160
|
);
|
|
@@ -166,10 +171,11 @@ export function finalizeStage(
|
|
|
166
171
|
const data = {
|
|
167
172
|
...stage,
|
|
168
173
|
hostStats: hostStatsArr, executorStats: executorStatsArr, failureReasons: failureReasonsArr, localityStats: localityStatsArr, stragglerCount, stragglerExcessMs, longestNonStragglerMs,
|
|
174
|
+
tailReplayRecoveryMs,
|
|
169
175
|
failedTaskSamples,
|
|
170
176
|
peakExecutionMemoryMax,
|
|
171
177
|
taskActiveMs: computeTaskActiveMs(arr),
|
|
172
|
-
peakConcurrentTasks
|
|
178
|
+
peakConcurrentTasks,
|
|
173
179
|
taskDurationP50: p50,
|
|
174
180
|
taskDurationP95: p95,
|
|
175
181
|
taskDurationMax: max,
|
|
@@ -234,6 +240,52 @@ export function computePeakConcurrentTasks(arr ) {
|
|
|
234
240
|
return peak;
|
|
235
241
|
}
|
|
236
242
|
|
|
243
|
+
// Wall-clock a tail fix recovers, replayed from the stage's own tasks: list scheduling (tasks in
|
|
244
|
+
// launch order, each on the slot that frees first) over `slots` slots, once with the real
|
|
245
|
+
// durations and once with every task over 4x P50 (the straggler definition above) capped at P50.
|
|
246
|
+
// The difference is the claim; dev/eval-tail-replay.mjs keeps an independent copy as its ground
|
|
247
|
+
// truth. Equal free times are interchangeable slots, so which one a min-heap picks can't change
|
|
248
|
+
// the result. Equal launch times keep the task array's order.
|
|
249
|
+
export function computeTailReplayRecoveryMs(arr , p50 , slots ) {
|
|
250
|
+
const taskCount = arr.length / FIELDS.STRIDE;
|
|
251
|
+
if (taskCount < 2 || !(p50 > 0)) return 0;
|
|
252
|
+
const order = new Uint32Array(taskCount);
|
|
253
|
+
for (let i = 0; i < taskCount; i++) order[i] = i;
|
|
254
|
+
order.sort((a, b) => arr[a * FIELDS.STRIDE + FIELDS.LAUNCH_TIME] - arr[b * FIELDS.STRIDE + FIELDS.LAUNCH_TIME] || a - b);
|
|
255
|
+
const free = new Float64Array(Math.max(1, Math.min(slots, taskCount)));
|
|
256
|
+
const capAboveMs = 4 * p50;
|
|
257
|
+
const actualEndMs = listScheduleEndMs(arr, order, free, Infinity, p50);
|
|
258
|
+
const fixedEndMs = listScheduleEndMs(arr, order, free, capAboveMs, p50);
|
|
259
|
+
return Math.max(0, actualEndMs - fixedEndMs);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// End of a list schedule over `free` (a min-heap of slot free times, reset here), each task's
|
|
263
|
+
// duration replaced by `cappedMs` when over `capAboveMs`.
|
|
264
|
+
function listScheduleEndMs(
|
|
265
|
+
arr , order , free , capAboveMs , cappedMs ,
|
|
266
|
+
) {
|
|
267
|
+
free.fill(0);
|
|
268
|
+
const n = free.length;
|
|
269
|
+
let endMs = 0;
|
|
270
|
+
for (let t = 0; t < order.length; t++) {
|
|
271
|
+
const duration = arr[order[t] * FIELDS.STRIDE + FIELDS.DURATION];
|
|
272
|
+
const finish = free[0] + (duration > capAboveMs ? cappedMs : duration);
|
|
273
|
+
if (finish > endMs) endMs = finish;
|
|
274
|
+
// Replace the root (earliest free slot) and sift it down.
|
|
275
|
+
let i = 0;
|
|
276
|
+
for (;;) {
|
|
277
|
+
const left = 2 * i + 1;
|
|
278
|
+
if (left >= n) break;
|
|
279
|
+
const child = left + 1 < n && free[left + 1] < free[left] ? left + 1 : left;
|
|
280
|
+
if (free[child] >= finish) break;
|
|
281
|
+
free[i] = free[child];
|
|
282
|
+
i = child;
|
|
283
|
+
}
|
|
284
|
+
free[i] = finish;
|
|
285
|
+
}
|
|
286
|
+
return endMs;
|
|
287
|
+
}
|
|
288
|
+
|
|
237
289
|
export function computeFieldQuantiles(arr , fieldIndex ) {
|
|
238
290
|
const taskCount = arr.length / FIELDS.STRIDE;
|
|
239
291
|
if (taskCount === 0) return { p50: 0, p95: 0, max: 0 };
|
package/vendor-core/types.js
CHANGED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// Parse-worker side of the zstd decompress worker (zstd-worker.ts). Builds a ZstdDecoderFactory
|
|
2
|
+
// for streamFile whose push() ships each compressed read slice to the decompress worker and
|
|
3
|
+
// returns while the worker decodes it, so the parse worker parses slice N's output while the
|
|
4
|
+
// decompress worker decodes slice N+1.
|
|
5
|
+
//
|
|
6
|
+
// Flow control is a window of input slices: push() resolves once fewer than `maxInFlight`
|
|
7
|
+
// slices are unacknowledged, and a slice is acknowledged only when its `consumed` reply is
|
|
8
|
+
// handled, which comes after all of its decoded chunks (each fed to onChunk from the message
|
|
9
|
+
// handler). So at most `maxInFlight` slices' output is ever queued on the parse worker, and a
|
|
10
|
+
// fast decompressor on a slow parse cannot grow memory without bound. The final push resolves
|
|
11
|
+
// only after every chunk was fed, which is what streamFile's callers need before they flush.
|
|
12
|
+
//
|
|
13
|
+
// Buffers move by transfer both ways: push() takes ownership of the slice it is given.
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
// Three 512 KiB slices of a ~20x-compressed log keep about 30 MB of output queued at most,
|
|
18
|
+
// and give the decompress worker a slice of slack while the parse worker reads the next one.
|
|
19
|
+
export const MAX_IN_FLIGHT_SLICES = 3;
|
|
20
|
+
|
|
21
|
+
// The slice of the Worker API this client uses; tests pass a MessagePort adapter.
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
// `spawn` starts the decompress worker; it runs at most once, on the first zstd stream, and the
|
|
42
|
+
// worker then serves every later stream (the files of a rolling log) one at a time. When it
|
|
43
|
+
// cannot start (no nested workers, a blocked script, a crash), each stream is decoded by
|
|
44
|
+
// `fallback` on the calling thread instead, the path used before this worker existed.
|
|
45
|
+
export function createWorkerZstdDecoders(
|
|
46
|
+
spawn ,
|
|
47
|
+
fallback ,
|
|
48
|
+
{ maxInFlight = MAX_IN_FLIGHT_SLICES, onFallback } = {},
|
|
49
|
+
) {
|
|
50
|
+
let port = null;
|
|
51
|
+
let ready = null;
|
|
52
|
+
let active = null;
|
|
53
|
+
let nextId = 0;
|
|
54
|
+
|
|
55
|
+
const giveUp = (reason ) => {
|
|
56
|
+
port?.terminate?.();
|
|
57
|
+
port = null;
|
|
58
|
+
ready = Promise.resolve(false);
|
|
59
|
+
onFallback?.(reason);
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const startWorker = () => new Promise((resolve) => {
|
|
63
|
+
let candidate ;
|
|
64
|
+
try {
|
|
65
|
+
candidate = spawn();
|
|
66
|
+
} catch (e) {
|
|
67
|
+
giveUp(`could not start the decompress worker: ${e instanceof Error ? e.message : String(e)}`);
|
|
68
|
+
resolve(false);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
let settled = false;
|
|
72
|
+
const settle = (ok , reason = '') => {
|
|
73
|
+
if (settled) return;
|
|
74
|
+
settled = true;
|
|
75
|
+
if (ok) port = candidate;
|
|
76
|
+
else {
|
|
77
|
+
candidate.terminate?.();
|
|
78
|
+
giveUp(reason);
|
|
79
|
+
}
|
|
80
|
+
resolve(ok);
|
|
81
|
+
};
|
|
82
|
+
candidate.onmessage = ({ data }) => {
|
|
83
|
+
if (data.type === 'ready') settle(true);
|
|
84
|
+
else if (active && 'id' in data && data.id === active.id) active.onReply(data);
|
|
85
|
+
};
|
|
86
|
+
candidate.onerror = (ev) => {
|
|
87
|
+
// Handled here: left alone, a nested worker's error also reaches the parse worker's own
|
|
88
|
+
// global handler and from there the page's "Worker crashed" path.
|
|
89
|
+
ev.preventDefault();
|
|
90
|
+
const message = ev.message || 'unknown error';
|
|
91
|
+
if (!settled) {
|
|
92
|
+
settle(false, `the decompress worker failed to load: ${message}`);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
const stream = active;
|
|
96
|
+
giveUp(`the decompress worker crashed: ${message}`);
|
|
97
|
+
stream?.fail(new Error(`Decompress worker crashed: ${message}`));
|
|
98
|
+
};
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
return (onChunk) => {
|
|
102
|
+
const id = nextId++;
|
|
103
|
+
let started = false;
|
|
104
|
+
let local = null;
|
|
105
|
+
let seq = 0;
|
|
106
|
+
let inFlight = 0;
|
|
107
|
+
let failure = null;
|
|
108
|
+
let wake = null;
|
|
109
|
+
|
|
110
|
+
const until = (done ) => new Promise ((resolve) => {
|
|
111
|
+
const check = () => {
|
|
112
|
+
if (!done()) return;
|
|
113
|
+
wake = null;
|
|
114
|
+
resolve();
|
|
115
|
+
};
|
|
116
|
+
wake = check;
|
|
117
|
+
check();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
const stream = {
|
|
121
|
+
id,
|
|
122
|
+
onReply(msg) {
|
|
123
|
+
if (failure) return;
|
|
124
|
+
if (msg.type === 'chunk') {
|
|
125
|
+
try {
|
|
126
|
+
onChunk(new Uint8Array(msg.bytes, 0, msg.length));
|
|
127
|
+
} catch (e) {
|
|
128
|
+
port?.postMessage({ type: 'cancel', id }, []);
|
|
129
|
+
stream.fail(e instanceof Error ? e : new Error(String(e)));
|
|
130
|
+
}
|
|
131
|
+
} else if (msg.type === 'consumed') {
|
|
132
|
+
inFlight--;
|
|
133
|
+
wake?.();
|
|
134
|
+
} else if (msg.type === 'error') {
|
|
135
|
+
stream.fail(new Error(msg.message));
|
|
136
|
+
}
|
|
137
|
+
},
|
|
138
|
+
fail(err) {
|
|
139
|
+
if (failure) return;
|
|
140
|
+
failure = err;
|
|
141
|
+
if (active === stream) active = null;
|
|
142
|
+
wake?.();
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const begin = async () => {
|
|
147
|
+
started = true;
|
|
148
|
+
ready ??= startWorker();
|
|
149
|
+
if (!(await ready) || !port) {
|
|
150
|
+
local = fallback(onChunk);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
active = stream;
|
|
154
|
+
port.postMessage({ type: 'start', id }, []);
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
async push(chunk, final = false) {
|
|
159
|
+
if (!started) await begin();
|
|
160
|
+
if (local) return local.push(chunk, final);
|
|
161
|
+
if (failure) throw failure;
|
|
162
|
+
// Transfer the slice's own buffer when it spans all of it; copy a view of a larger one.
|
|
163
|
+
const bytes = chunk.byteOffset === 0 && chunk.byteLength === chunk.buffer.byteLength
|
|
164
|
+
? chunk.buffer
|
|
165
|
+
: chunk.slice().buffer;
|
|
166
|
+
if (!port) throw new Error('Decompress worker is gone');
|
|
167
|
+
port.postMessage({ type: 'data', id, seq: seq++, bytes, final }, [bytes]);
|
|
168
|
+
inFlight++;
|
|
169
|
+
await until(() => failure !== null || inFlight < (final ? 1 : maxInFlight));
|
|
170
|
+
if (failure) throw failure;
|
|
171
|
+
if (final && active === stream) active = null;
|
|
172
|
+
},
|
|
173
|
+
cancel() {
|
|
174
|
+
if (local || !started || failure) return;
|
|
175
|
+
port?.postMessage({ type: 'cancel', id }, []);
|
|
176
|
+
stream.fail(new Error('Decompression cancelled'));
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
};
|
|
180
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// Decompress worker: runs the vendored fzstd off the parse worker's thread, so decompression and
|
|
2
|
+
// NDJSON parsing of a zstd log overlap instead of taking turns. The parse worker spawns it and
|
|
3
|
+
// drives it through zstd-worker-client.ts; both ends of the message protocol live in the types
|
|
4
|
+
// below, and zstd-worker-client.ts documents the flow control.
|
|
5
|
+
import { Decompress as ZstdDecompress } from './vendor/fzstd.js';
|
|
6
|
+
|
|
7
|
+
// Parse worker -> decompress worker. `seq` numbers a stream's input slices from 0.
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
// Decompress worker -> parse worker. Every `chunk` for input slice `seq` is posted before that
|
|
14
|
+
// slice's `consumed`, and a stream's `error` is the last message it gets.
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
// fzstd emits one chunk per zstd block (128 KiB at most). Batching them into 1 MiB messages cuts
|
|
24
|
+
// the per-message cost on both threads about eight-fold.
|
|
25
|
+
export const OUTPUT_BATCH_BYTES = 1024 * 1024;
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
// The decompress side of the protocol, transport-free so tests can drive it over a
|
|
31
|
+
// MessageChannel. Holds at most one live stream: `start` replaces it, and a message for any
|
|
32
|
+
// other stream id (one already cancelled or failed) is dropped.
|
|
33
|
+
export function createZstdWorkerHandler(
|
|
34
|
+
post ,
|
|
35
|
+
batchBytes = OUTPUT_BATCH_BYTES,
|
|
36
|
+
) {
|
|
37
|
+
let streamId = -1;
|
|
38
|
+
let decoder = null;
|
|
39
|
+
let batch = new Uint8Array(batchBytes);
|
|
40
|
+
let batchUsed = 0;
|
|
41
|
+
|
|
42
|
+
// Hand `batch` over without copying it again: transfer its whole buffer with the filled
|
|
43
|
+
// length and start a fresh one. fzstd's chunk is a view of a buffer it reuses, so the copy
|
|
44
|
+
// into `batch` is the one copy this path cannot avoid.
|
|
45
|
+
const flush = () => {
|
|
46
|
+
if (batchUsed === 0) return;
|
|
47
|
+
const bytes = batch.buffer ;
|
|
48
|
+
post({ type: 'chunk', id: streamId, bytes, length: batchUsed }, [bytes]);
|
|
49
|
+
batch = new Uint8Array(batchBytes);
|
|
50
|
+
batchUsed = 0;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const collect = (chunk ) => {
|
|
54
|
+
let offset = 0;
|
|
55
|
+
while (offset < chunk.length) {
|
|
56
|
+
const n = Math.min(chunk.length - offset, batchBytes - batchUsed);
|
|
57
|
+
batch.set(chunk.subarray(offset, offset + n), batchUsed);
|
|
58
|
+
batchUsed += n;
|
|
59
|
+
offset += n;
|
|
60
|
+
if (batchUsed === batchBytes) flush();
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const reset = () => {
|
|
65
|
+
decoder = null;
|
|
66
|
+
batchUsed = 0;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
return (msg) => {
|
|
70
|
+
if (msg.type === 'start') {
|
|
71
|
+
streamId = msg.id;
|
|
72
|
+
batchUsed = 0;
|
|
73
|
+
decoder = new (ZstdDecompress )(collect);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
if (msg.id !== streamId || !decoder) return;
|
|
77
|
+
if (msg.type === 'cancel') {
|
|
78
|
+
reset();
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
try {
|
|
82
|
+
decoder.push(new Uint8Array(msg.bytes), msg.final);
|
|
83
|
+
flush();
|
|
84
|
+
} catch (e) {
|
|
85
|
+
reset();
|
|
86
|
+
post({ type: 'error', id: msg.id, message: e instanceof Error ? e.message : String(e) });
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
post({ type: 'consumed', id: msg.id, seq: msg.seq });
|
|
90
|
+
if (msg.final) reset();
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ─── Worker message bus (only active when running as a Web Worker) ──────────────
|
|
95
|
+
|
|
96
|
+
const isWorker = typeof WorkerGlobalScope !== 'undefined' && self instanceof WorkerGlobalScope;
|
|
97
|
+
|
|
98
|
+
if (isWorker) {
|
|
99
|
+
const scope = self ;
|
|
100
|
+
const handle = createZstdWorkerHandler((msg, transfer) => scope.postMessage(msg, transfer ?? []));
|
|
101
|
+
scope.onmessage = ({ data } ) => handle(data);
|
|
102
|
+
scope.postMessage({ type: 'ready' } );
|
|
103
|
+
}
|