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.
@@ -19,20 +19,26 @@ async function loadCreateMcpServer() {
19
19
  return mod.createMcpServer;
20
20
  }
21
21
 
22
- const USAGE = `Usage: sparkforensics-mcp
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 five tools for diagnosing Apache Spark event logs:
27
- diagnose_run, get_run_summary, compare_runs, evaluate_budgets,
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
- process.stderr.write(USAGE);
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",
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 (diagnose_run, get_run_summary, compare_runs, get_finding_evidence, evaluate_budgets) over stdio.",
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": "./bin/sparkforensics-mcp.mjs"
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 has no data for more partitions to split (a
336
- // 1-task count stage open 27 minutes on 5s of CPU was claimed 99% recoverable): claim 0.
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 > 0 ? activeMs * Math.max(0, 1 - taskCount / totalCores) : 0;
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': {
@@ -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, given the slowest task's excess over the median. A
160
- // lone straggler costs that excess; a tail of many slow tasks (a bimodal stage: 26% of 1400
161
- // tasks over 4x P50 on a real log) costs its summed excess (stragglerExcessMs) spread over the
162
- // slots the stage had (peakConcurrentTasks), far more than one task's. The task-level replay in
163
- // dev/eval-tail-replay.mjs recovers about the larger of the two. Average concurrency would be the
164
- // wrong divisor: a tail-dominated stage runs few tasks for most of its span (5.7 average vs 14
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 may decompress off the main thread: streamFile awaits each push.
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 = codec !== 'zstd' ? null
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
- while (offset < file.size) {
105
- const start = offset;
106
- const slice = new Uint8Array(await file.slice(start, start + stepSize).arrayBuffer());
107
- offset += stepSize;
108
- const final = offset >= file.size;
109
- currentPct = start / file.size;
110
- if (gunzip) gunzip.push(slice, final);
111
- else if (lz4) lz4.push(slice);
112
- else if (zstd) await zstd.push(slice, final);
113
- else if (snappy) snappy.push(slice);
114
- else onChunk(slice, currentPct);
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.t_emp_whitelist", "parquet:business_prd.sales", "jdbc:dw.d_producto")
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: computePeakConcurrentTasks(arr),
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 };
@@ -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
+ }