sparkforensics-cli 0.1.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.
Files changed (60) hide show
  1. package/bin/sparkforensics-analyze.mjs +288 -0
  2. package/package.json +29 -0
  3. package/vendor-core/analyzer.js +167 -0
  4. package/vendor-core/assert-never.js +3 -0
  5. package/vendor-core/cli/budgets.js +203 -0
  6. package/vendor-core/cli/collect-run.js +107 -0
  7. package/vendor-core/core-count.js +68 -0
  8. package/vendor-core/core-locality-ratio.js +54 -0
  9. package/vendor-core/core-time-series.js +92 -0
  10. package/vendor-core/core-usage-locality.js +70 -0
  11. package/vendor-core/detectors.js +1989 -0
  12. package/vendor-core/docs-config.js +72 -0
  13. package/vendor-core/docs-site-config.js +23 -0
  14. package/vendor-core/efficiency-model.js +62 -0
  15. package/vendor-core/etl-phases.js +28 -0
  16. package/vendor-core/event-handlers.js +906 -0
  17. package/vendor-core/event-schemas.js +405 -0
  18. package/vendor-core/evidence-availability.js +121 -0
  19. package/vendor-core/evidence-report.js +459 -0
  20. package/vendor-core/finding-action-label.js +97 -0
  21. package/vendor-core/finding-filter-predicate.js +38 -0
  22. package/vendor-core/format-utils.js +167 -0
  23. package/vendor-core/impact-band.js +50 -0
  24. package/vendor-core/impact-estimator.js +428 -0
  25. package/vendor-core/ingest.js +139 -0
  26. package/vendor-core/job-groups.js +30 -0
  27. package/vendor-core/load-vendored.js +24 -0
  28. package/vendor-core/lz4-block.js +135 -0
  29. package/vendor-core/mcp-error.js +3 -0
  30. package/vendor-core/mcp-server-factory.js +115 -0
  31. package/vendor-core/mcp-tools.js +331 -0
  32. package/vendor-core/model-assembler.js +76 -0
  33. package/vendor-core/occupancy.js +202 -0
  34. package/vendor-core/parser-worker.js +249 -0
  35. package/vendor-core/plan-dot.js +25 -0
  36. package/vendor-core/plan-duration-attribution.js +185 -0
  37. package/vendor-core/plan-graph-model.js +171 -0
  38. package/vendor-core/plan-node-detail.js +159 -0
  39. package/vendor-core/plan-summary.js +233 -0
  40. package/vendor-core/plan-tree-walk.js +29 -0
  41. package/vendor-core/proxy.js +157 -0
  42. package/vendor-core/recommendation-rollup.js +197 -0
  43. package/vendor-core/redact.js +175 -0
  44. package/vendor-core/rolling-log-reassembly.js +52 -0
  45. package/vendor-core/run-aggregates.js +44 -0
  46. package/vendor-core/run-comparison.js +458 -0
  47. package/vendor-core/scaling-sim.js +73 -0
  48. package/vendor-core/session-snapshot.js +79 -0
  49. package/vendor-core/shs-fetch.js +196 -0
  50. package/vendor-core/shs-load.js +121 -0
  51. package/vendor-core/shs-request.js +101 -0
  52. package/vendor-core/shs-schemas.js +13 -0
  53. package/vendor-core/snappy-block.js +140 -0
  54. package/vendor-core/stage-quantiles.js +199 -0
  55. package/vendor-core/threshold-summary.js +35 -0
  56. package/vendor-core/types.js +286 -0
  57. package/vendor-core/vendor/fflate.js +2695 -0
  58. package/vendor-core/vendor/fzstd.js +768 -0
  59. package/vendor-core/wall-clock.js +36 -0
  60. package/vendor-core/wasted-core-hours.js +68 -0
@@ -0,0 +1,249 @@
1
+ import { Gunzip } from './vendor/fflate.js';
2
+ import { createLz4BlockDecoder } from './lz4-block.js';
3
+ import { Decompress as ZstdDecompress } from './vendor/fzstd.js';
4
+ import { createSnappyBlockDecoder } from './snappy-block.js';
5
+ import { createState, dispatchLine, buildChunkDecoder, emitParseCompletion, } from './event-handlers.js';
6
+ import { TASK_FIELD_NAMES } from './stage-quantiles.js';
7
+ import { runParseFromUrl, sniffCodec } from './shs-fetch.js';
8
+
9
+ export {
10
+ buildChunkDecoder, createState, normalizeSparkProperties, parseSparkMemoryMB, extractResources,
11
+ accumulateTask, resolvePlanTree, startApplication, updateEnvironment, startJob, endJob, submitStage,
12
+ mergeStageRddInfo, recordStageExecutorMetrics, startSqlExecution, endSqlExecution,
13
+ applyDriverAccumUpdates, addExecutor, removeExecutor, processEvent, dispatchLine,
14
+ collectStageExecutorMetrics,
15
+ } from './event-handlers.js';
16
+
17
+ export {
18
+ FIELDS, TASK_FIELD_NAMES, finalizeStage, computeFieldQuantiles, computeDurationQuantiles, classifySpill,
19
+ } from './stage-quantiles.js';
20
+
21
+ export {
22
+ runParseFromUrl, sniffCodec, decodeShsArchive,
23
+ } from './shs-fetch.js';
24
+
25
+ export { naturalCompare, reassembleRollingEntries } from './rolling-log-reassembly.js';
26
+
27
+ // 512 KB rather than a larger round number: Spark event logs commonly
28
+ // compress ~15-20x, so a coarser chunk decompresses into a burst of tens of
29
+ // thousands of lines reported at one unmoving pct before jumping: smaller
30
+ // reads give the progress bar more checkpoints to advance through instead.
31
+ const CHUNK_SIZE = 512 * 1024;
32
+ const MIN_PROGRESS_STEPS = 100;
33
+ // Emitting every 2000 lines was the other half of the stair-step: a highly
34
+ // compressed chunk can decode thousands of lines in one go, so 2000 was
35
+ // rarely a mid-chunk boundary: it just meant a handful of progress
36
+ // messages for the whole file, each landing on a different currentPct.
37
+ const PROGRESS_EMIT_LINES = 300;
38
+
39
+
40
+
41
+
42
+ // Minimal shape streamFile/runParse/runParseFiles actually read off `file`
43
+ // (name, size, slice(start,end).arrayBuffer()): narrower than the full DOM
44
+ // `File` interface. A real `File` (the browser Worker path, via
45
+ // WorkerIncomingMessage below) satisfies this structurally, but so does the
46
+ // plain object src/cli/collect-run.ts's nodeFileFromPath builds for Node
47
+ // (which has no DOM `File` constructor to build a real one from). Widened
48
+ // from the plan's literal `File` to match that real second caller rather
49
+ // than forcing a cast at its call site.
50
+
51
+
52
+
53
+
54
+
55
+
56
+ // Incoming message shapes accepted by the worker's `self.onmessage` handler.
57
+
58
+
59
+
60
+
61
+
62
+
63
+ // fflate's Gunzip and fzstd's Decompress are untyped vendor JS (plain
64
+ // prototype classes, not `class` declarations), so TS can't infer a
65
+ // construct signature for them; this local shape is just enough to type
66
+ // the two call sites below without touching the vendored files (mirrors
67
+ // shs-fetch.ts's identical StreamingDecoder shim).
68
+
69
+
70
+
71
+ // Stream one File's (possibly compressed) bytes through the codec dispatch,
72
+ // in `chunkSize` slices, invoking `onChunk` with each decompressed buffer as
73
+ // it becomes available. Shared by runParse (single file) and runParseFiles
74
+ // (rolling multi-file directories): the codec is sniffed per-file since
75
+ // Spark's file-rolling never spans a compressor's own framing.
76
+ async function streamFile(
77
+ file ,
78
+ onChunk ,
79
+ chunkSize ,
80
+ ) {
81
+ const header = new Uint8Array(await file.slice(0, Math.min(8, file.size)).arrayBuffer());
82
+ const codec = sniffCodec(header);
83
+
84
+ // Codec decoders' push() can synchronously invoke their onChunk callback,
85
+ // which was bound once, before the loop below has a `start` to report:
86
+ // route pct through this shared mutable slot instead, updated ahead of
87
+ // each push so compressed codecs report bytes-read progress too, not just
88
+ // the uncompressed branch.
89
+ let currentPct = 0;
90
+ const gunzip = codec === 'gz' ? new (Gunzip )((inflated) => onChunk(inflated, currentPct)) : null;
91
+ const lz4 = codec === 'lz4' ? createLz4BlockDecoder((inflated) => onChunk(inflated, currentPct)) : null;
92
+ const zstd = codec === 'zstd' ? new (ZstdDecompress )((inflated) => onChunk(inflated, currentPct)) : null;
93
+ const snappy = codec === 'snappy' ? createSnappyBlockDecoder((inflated) => onChunk(inflated, currentPct)) : null;
94
+
95
+ // A fixed read size gives too few progress checkpoints on smaller files
96
+ // (e.g. a 3 MB log at 512 KB reads is only ~6 steps, ~15% jumps): cap the
97
+ // read size so every file gets at least MIN_PROGRESS_STEPS reads, however
98
+ // small, while chunkSize still bounds it above for large-file I/O.
99
+ const stepSize = Math.max(1, Math.min(chunkSize, Math.ceil(file.size / MIN_PROGRESS_STEPS)));
100
+
101
+ let offset = 0;
102
+ while (offset < file.size) {
103
+ const start = offset;
104
+ const slice = new Uint8Array(await file.slice(start, start + stepSize).arrayBuffer());
105
+ offset += stepSize;
106
+ const final = offset >= file.size;
107
+ currentPct = start / file.size;
108
+ if (gunzip) gunzip.push(slice, final);
109
+ else if (lz4) lz4.push(slice);
110
+ else if (zstd) zstd.push(slice, final);
111
+ else if (snappy) snappy.push(slice);
112
+ else onChunk(slice, currentPct);
113
+ }
114
+ if (lz4) lz4.end();
115
+ if (snappy) snappy.end();
116
+ }
117
+
118
+ // Parse a dropped `File`, always streaming to honor the core invariant: the
119
+ // decompressed task-event stream must never be buffered whole. The file is read
120
+ // in `chunkSize` slices; a compressed log (gzip, Zstandard, Spark LZ4Block, or
121
+ // Spark's Snappy framing) is inflated incrementally so only one decompressed
122
+ // chunk is live at a time, exactly like the uncompressed path.
123
+ // `chunkSize` is injectable purely so tests can force multi-chunk streaming.
124
+ export async function runParse(
125
+ file ,
126
+ state ,
127
+ { emit = (msg ) => self.postMessage(msg), chunkSize = CHUNK_SIZE } = {},
128
+ ) {
129
+ if (file.size === 0) {
130
+ emit({ type: 'error', message: 'File is empty.' });
131
+ return;
132
+ }
133
+
134
+ const decoder = buildChunkDecoder();
135
+ let linesProcessed = 0;
136
+ const feed = (bytes , pct ) => {
137
+ for (const line of decoder.decode(bytes)) {
138
+ dispatchLine(line, state, emit);
139
+ linesProcessed++;
140
+ if (linesProcessed % PROGRESS_EMIT_LINES === 0) {
141
+ emit({ type: 'progress', pct: pct ?? null, linesProcessed });
142
+ }
143
+ }
144
+ };
145
+
146
+ try {
147
+ await streamFile(file, feed, chunkSize);
148
+ } catch (e) {
149
+ const message = e instanceof Error ? e.message : String(e);
150
+ emit({ type: 'error', message: `Could not decompress "${file.name}": ${message}` });
151
+ return;
152
+ }
153
+
154
+ for (const line of decoder.flush()) {
155
+ dispatchLine(line, state, emit);
156
+ }
157
+
158
+ if (!state.app) {
159
+ emit({ type: 'error', message: 'Not a Spark event log: SparkListenerApplicationStart not found.' });
160
+ return;
161
+ }
162
+
163
+ emitParseCompletion(state, emit, linesProcessed);
164
+ }
165
+
166
+ // Parse a rolling `eventlog_v2_*` directory: an ordered array of File objects
167
+ // (already deduped/sorted by reassembleRollingEntries) representing
168
+ // `events_<index>_...` files. Reuses the same chunked streaming codec
169
+ // dispatch as runParse, once per file in sequence, but keeps ONE NDJSON
170
+ // line-decoder alive across all files: nothing in the rolling-log format
171
+ // guarantees a file-roll boundary lands on a line boundary.
172
+ export async function runParseFiles(
173
+ files ,
174
+ state ,
175
+ { emit = (msg ) => self.postMessage(msg), chunkSize = CHUNK_SIZE } = {},
176
+ ) {
177
+ if (files.length === 0) {
178
+ emit({ type: 'error', message: 'Rolling event-log directory contained no event files.' });
179
+ return;
180
+ }
181
+
182
+ const decoder = buildChunkDecoder();
183
+ let linesProcessed = 0;
184
+ const totalSize = files.reduce((sum, f) => sum + f.size, 0);
185
+ let bytesBeforeCurrentFile = 0;
186
+ let currentFileSize = 0;
187
+ const feed = (bytes , pct ) => {
188
+ for (const line of decoder.decode(bytes)) {
189
+ dispatchLine(line, state, emit);
190
+ linesProcessed++;
191
+ if (linesProcessed % PROGRESS_EMIT_LINES === 0) {
192
+ const overallPct = totalSize > 0 ? (bytesBeforeCurrentFile + (pct ?? 0) * currentFileSize) / totalSize : null;
193
+ emit({ type: 'progress', pct: overallPct, linesProcessed });
194
+ }
195
+ }
196
+ };
197
+
198
+ for (const file of files) {
199
+ currentFileSize = file.size;
200
+ try {
201
+ await streamFile(file, feed, chunkSize);
202
+ } catch (e) {
203
+ const message = e instanceof Error ? e.message : String(e);
204
+ emit({ type: 'error', message: `Could not decompress "${file.name}": ${message}` });
205
+ return;
206
+ }
207
+ bytesBeforeCurrentFile += file.size;
208
+ }
209
+
210
+ for (const line of decoder.flush()) {
211
+ dispatchLine(line, state, emit);
212
+ }
213
+
214
+ if (!state.app) {
215
+ emit({ type: 'error', message: 'Not a Spark event log: SparkListenerApplicationStart not found.' });
216
+ return;
217
+ }
218
+
219
+ emitParseCompletion(state, emit, linesProcessed);
220
+ }
221
+
222
+ // ─── Worker message bus (only active when running as a Web Worker) ──────────────
223
+
224
+ const isWorker = typeof WorkerGlobalScope !== 'undefined' && self instanceof WorkerGlobalScope;
225
+
226
+ if (isWorker) {
227
+ let workerState = null;
228
+
229
+ self.onmessage = async ({ data } ) => {
230
+ if (data.type === 'parse') {
231
+ workerState = createState();
232
+ await runParse(data.file, workerState);
233
+ } else if (data.type === 'parseFromUrl') {
234
+ workerState = createState();
235
+ await runParseFromUrl(data.request, workerState);
236
+ } else if (data.type === 'parseFiles') {
237
+ workerState = createState();
238
+ await runParseFiles(data.files, workerState);
239
+ } else if (data.type === 'getTaskData') {
240
+ const { stageId, reqId } = data;
241
+ const stored = workerState?.taskStore.get(stageId) ?? new Float64Array(0);
242
+ self.postMessage({
243
+ type: 'taskData', stageId, reqId,
244
+ metrics: stored.slice(),
245
+ fieldNames: TASK_FIELD_NAMES,
246
+ });
247
+ }
248
+ };
249
+ }
@@ -0,0 +1,25 @@
1
+ // Graphviz DOT export of a resolved planTree (sparkDoctor SqlPlanDotWriter).
2
+ // Plain string building, no dependency, no metric annotation yet (deferred,
3
+ // matching sparkDoctor's own unimplemented roadmap item).
4
+
5
+ import { walkPlanTree } from './plan-tree-walk.js';
6
+
7
+ function esc(s ) { return String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"'); }
8
+
9
+ export function planTreeToDot(planTree , { title = 'plan' } = {}) {
10
+ if (!planTree) return '';
11
+ const lines = [`digraph "${esc(title)}" {`, ' rankdir=BT;', ' node [shape=box];'];
12
+ const idOf = new WeakMap ();
13
+ const edges = [];
14
+ let counter = 0;
15
+ walkPlanTree(planTree, (node, parent) => {
16
+ const id = `n${counter++}`;
17
+ idOf.set(node, id);
18
+ const label = node.detail && node.detail !== node.name ? `${node.name}\\n${node.detail}` : node.name;
19
+ lines.push(` ${id} [label="${esc(label)}"];`);
20
+ // Pre-order guarantee: parent's id is already assigned by the time a
21
+ // child is visited, so a single walk covers both node labels and edges.
22
+ if (parent) edges.push(` ${idOf.get(parent)} -> ${id};`);
23
+ }, { dedupe: true });
24
+ return [...lines, ...edges, '}'].join('\n');
25
+ }
@@ -0,0 +1,185 @@
1
+ // Approximate stage wall-time back to plan operators, splitting the tree at
2
+ // Exchange boundaries into segments and zipping segments (deepest-first) to
3
+ // submission-ordered stage IDs. No exact ground truth exists in Spark's event
4
+ // model: this is inference (dataflint's GraphDurationAttribution approach).
5
+ import { walkPlanTree } from './plan-tree-walk.js';
6
+
7
+
8
+
9
+
10
+ function isExchange(node ) { return /exchange/i.test(node.name); }
11
+
12
+ function timingMs(node ) {
13
+ for (const m of (node.metrics ?? [])) {
14
+ if (m.metricType === 'timing') return m.value;
15
+ if (m.metricType === 'nsTiming') return m.value / 1e6;
16
+ }
17
+ for (const m of (node.metrics ?? [])) {
18
+ if (/time/i.test(m.name) && typeof m.value === 'number') return m.value;
19
+ }
20
+ return null;
21
+ }
22
+
23
+ // Group nodes into connected components after cutting each Exchange -> child
24
+ // boundary. Component ids are stable pre-order identities only; depth,
25
+ // traversal order, and parent relationships live in segmentTopology so no
26
+ // consumer has to infer plan topology from an id's numeric value. A fresh id
27
+ // is required for every cut: sibling producer branches at the same depth are
28
+ // separate components, not one shared "depth" segment.
29
+ export function computeSegments(planTree )
30
+
31
+
32
+
33
+ {
34
+ const segments = [];
35
+ const segOf = new Map ();
36
+ const segmentTopology = new Map();
37
+ if (!planTree) return { segments, segOf, segmentTopology };
38
+ let nextSegment = 1;
39
+ let nextTraversalOrder = 0;
40
+ walkPlanTree(planTree, (node, parent) => {
41
+ let seg ;
42
+ if (!parent) {
43
+ seg = 0;
44
+ segmentTopology.set(seg, { parentIndex: null, depth: 0, traversalOrder: nextTraversalOrder++ });
45
+ } else if (isExchange(parent)) {
46
+ const parentIndex = segOf.get(parent) ;
47
+ seg = nextSegment++;
48
+ segmentTopology.set(seg, {
49
+ parentIndex,
50
+ depth: segmentTopology.get(parentIndex) .depth + 1,
51
+ traversalOrder: nextTraversalOrder++,
52
+ });
53
+ } else {
54
+ seg = segOf.get(parent) ;
55
+ }
56
+ segOf.set(node, seg);
57
+ (segments[seg] ??= []).push(node);
58
+ }, { dedupe: true });
59
+ return { segments, segOf, segmentTopology };
60
+ }
61
+
62
+ // Deepest component executes first; equal-depth components retain plan
63
+ // traversal order. Earliest-submitted stage is first. Zips them positionally,
64
+ // truncating to whichever list is shorter: a component or stage past the
65
+ // shorter length's cutoff has no pair and is simply absent from `pairs`.
66
+ export function zipSegmentsToStages(
67
+ segments ,
68
+ stagesById ,
69
+ stageIds ,
70
+ segmentTopology ,
71
+ )
72
+
73
+
74
+
75
+ {
76
+ const orderedSegments = segments.filter(Boolean).map((nodes, i) => ({
77
+ i,
78
+ nodes,
79
+ ...segmentTopology.get(i) ,
80
+ })).sort((a, b) => b.depth - a.depth || a.traversalOrder - b.traversalOrder);
81
+ const orderedStages = [...stageIds]
82
+ .map(id => ({ id, s: stagesById.get(id) }))
83
+ .filter((x) => Boolean(x.s))
84
+ .sort((a, b) => a.s.submittedAt - b.s.submittedAt );
85
+
86
+ const n = Math.min(orderedSegments.length, orderedStages.length);
87
+ const pairs = [];
88
+ for (let k = 0; k < n; k++) {
89
+ pairs.push({
90
+ segmentIndex: orderedSegments[k].i,
91
+ stageId: orderedStages[k].id,
92
+ nodes: orderedSegments[k].nodes,
93
+ stage: orderedStages[k].s,
94
+ });
95
+ }
96
+ return { orderedSegments, orderedStages, pairs };
97
+ }
98
+
99
+ function componentDistance(fromIndex , toIndex , segmentTopology ) {
100
+ const fromAncestors = new Map ();
101
+ let current = fromIndex;
102
+ let distance = 0;
103
+ while (current != null) {
104
+ fromAncestors.set(current, distance++);
105
+ current = segmentTopology.get(current)?.parentIndex ?? null;
106
+ }
107
+
108
+ current = toIndex;
109
+ distance = 0;
110
+ while (current != null) {
111
+ const fromDistance = fromAncestors.get(current);
112
+ if (fromDistance != null) return fromDistance + distance;
113
+ current = segmentTopology.get(current)?.parentIndex ?? null;
114
+ distance++;
115
+ }
116
+ return Number.POSITIVE_INFINITY;
117
+ }
118
+
119
+ // For *display* purposes only (the "Stage N" label on a segment's group box
120
+ // in the plan graph): a segment past zipSegmentsToStages' Math.min(...)
121
+ // cutoff has no timing data of its own to pair on, but the physical plan
122
+ // still places it structurally near a component that DID win a stage slot
123
+ // (most often a small broadcast-builder component immediately upstream of the
124
+ // join component that consumes it). Inherit the structurally nearest strictly
125
+ // paired component's stage id rather than leaving its box an unexplained
126
+ // "Stage —". Distance is the number of parent/child edges in the component
127
+ // tree, never numeric id distance. Neighbor search is always against the
128
+ // ORIGINAL strict pairs, never against other already-filled components, so
129
+ // labels don't drift through a chain of inherited-from-inherited guesses.
130
+ // zipSegmentsToStages/attributeStageDurationToPlan are untouched by this and
131
+ // keep their own strict pairing: duration attribution must never double-
132
+ // count one stage's wall time across two segments.
133
+ export function mapSegmentsToStagesForDisplay(
134
+ segments ,
135
+ stagesById ,
136
+ stageIds ,
137
+ segmentTopology ,
138
+ ) {
139
+ const typedStagesById = stagesById ;
140
+ const typedTopology = segmentTopology ;
141
+ const { orderedSegments, pairs } = zipSegmentsToStages(segments, typedStagesById, stageIds, typedTopology);
142
+ const map = new Map (pairs.map((p) => [p.segmentIndex, p.stageId]));
143
+ const pairedIndices = pairs.map((p) => p.segmentIndex);
144
+ if (pairedIndices.length === 0) return map;
145
+
146
+ for (const { i } of orderedSegments ) {
147
+ if (map.has(i)) continue;
148
+ let nearest = pairedIndices[0];
149
+ let nearestDist = componentDistance(i, nearest, typedTopology);
150
+ for (const pi of pairedIndices) {
151
+ const dist = componentDistance(i, pi, typedTopology);
152
+ if (dist < nearestDist) { nearestDist = dist; nearest = pi; }
153
+ }
154
+ map.set(i, map.get(nearest) );
155
+ }
156
+ return map;
157
+ }
158
+
159
+ export function attributeStageDurationToPlan(
160
+ planTree ,
161
+ stagesById ,
162
+ sqlExec ,
163
+ ) {
164
+ const out = new Map ();
165
+ const stageIds = sqlExec?.stageIds ?? [];
166
+ if (!planTree || stageIds.length === 0) return out;
167
+
168
+ const { segments, segmentTopology } = computeSegments(planTree);
169
+ const typedStagesById = stagesById ;
170
+ const { pairs } = zipSegmentsToStages(segments, typedStagesById, stageIds, segmentTopology);
171
+
172
+ for (const { nodes, stage } of pairs) {
173
+ const { submittedAt, completedAt } = stage ;
174
+ const wall = Math.max(0, completedAt - submittedAt);
175
+ const weights = nodes.map(timingMs);
176
+ const totalTiming = weights.reduce ((a, w) => a + (w ?? 0), 0);
177
+ nodes.forEach((node, idx) => {
178
+ const share = totalTiming > 0
179
+ ? wall * ((weights[idx] ?? 0) / totalTiming)
180
+ : wall / nodes.length;
181
+ out.set(node, share);
182
+ });
183
+ }
184
+ return out;
185
+ }
@@ -0,0 +1,171 @@
1
+ // Flattens a resolved planTree into a { nodes, edges } graph shape for the
2
+ // React Flow + Dagre plan graph view ("Plan graph view" in
3
+ // docs-site/contributor-guide/architecture/drill-down.md). Every Exchange
4
+ // node is split into paired write/read halves: the
5
+ // write half stays with its children's producer segment and the read half
6
+ // stays with its parent's consumer segment. A duration share stays on the
7
+ // half in the original source node's attributed segment. Both halves share a
8
+ // `sourceNodeId` back-reference to the pre-split node's id, so any aggregate
9
+ // logic over nodes never double-counts one Exchange.
10
+ import { walkPlanTree } from './plan-tree-walk.js';
11
+ import {
12
+ classifyNode,
13
+ buildDurationMap,
14
+ parseOperatorDetail,
15
+ getPrimaryMetric,
16
+ } from './plan-node-detail.js';
17
+ import { computeSegments, mapSegmentsToStagesForDisplay } from './plan-duration-attribution.js';
18
+ import { stageIdsForSqlExec } from './detectors.js';
19
+
20
+
21
+ function isExchangeNode(node ) {
22
+ return classifyNode(node.name) === 'exchange';
23
+ }
24
+
25
+ function makeGraphNode({
26
+ id,
27
+ sourceNodeId,
28
+ planNode,
29
+ category,
30
+ segmentIndex,
31
+ splitRole,
32
+ durationShare,
33
+ }
34
+
35
+
36
+
37
+
38
+
39
+
40
+
41
+ ) {
42
+ return {
43
+ id,
44
+ sourceNodeId,
45
+ label: planNode.name,
46
+ category,
47
+ operatorDetail: parseOperatorDetail(planNode.name, planNode.detail ?? ''),
48
+ primaryMetric: getPrimaryMetric(planNode.metrics),
49
+ segmentIndex,
50
+ splitRole,
51
+ durationShare: durationShare ?? null,
52
+ };
53
+ }
54
+
55
+ export function buildPlanGraphModel(
56
+ planTree ,
57
+ opts ,
58
+ ) {
59
+ const { scope, stageId, appModel } = opts;
60
+ if (!planTree) return { nodes: [], edges: [], segmentIndex: null, segmentCount: 0, scope: 'full', segmentStageIds: new Map () };
61
+
62
+ const stage = appModel.stages.get(stageId);
63
+ // Coalesce undefined -> null so `sqlExecutionId` has a stable `number | null`
64
+ // type: every branch below narrows off the same variable instead of
65
+ // re-deriving it from `stage`, which TS can't re-narrow after the `stage?.`
66
+ // optional-chain expression above collapses to a plain boolean.
67
+ const sqlExecutionId = stage?.sqlExecutionId ?? null;
68
+ const sqlExec = sqlExecutionId != null ? appModel.sql.get(sqlExecutionId) : null;
69
+
70
+ const { segments, segOf, segmentTopology } = computeSegments(planTree);
71
+ const segmentCount = segments.filter(Boolean).length;
72
+
73
+ const durationMap =
74
+ sqlExec && sqlExecutionId != null ? buildDurationMap(planTree, appModel, sqlExec, sqlExecutionId) : null;
75
+
76
+ // Segment -> stage-id association, computed once up front regardless of
77
+ // `scope`: 'segment' needs it below to resolve which segment `stageId`
78
+ // belongs to, and 'full' needs the same map to label each segment's
79
+ // compound-group header (Task 11) with its stage id; it must not be
80
+ // computed only inside the 'segment' branch, or the full-plan view would
81
+ // have no stage-id data for its group headers. mapSegmentsToStagesForDisplay
82
+ // fills in segments that lost the strict zip's Math.min(segments, stages)
83
+ // pairing slot from their nearest paired neighbor, so a group box only
84
+ // falls back to an unexplained "Stage —" when there's truly no stage to
85
+ // borrow from (see its own doc comment); the earlier `targetSegmentIndex`
86
+ // lookup below stays correct either way, since a strictly-paired segment's
87
+ // canonical entry is always inserted before any inherited duplicate.
88
+ const segmentStageIds = new Map ();
89
+ const linkedStageIds =
90
+ sqlExec && sqlExecutionId != null ? stageIdsForSqlExec(sqlExecutionId, appModel.stages) : [];
91
+ if (linkedStageIds.length) {
92
+ const stagesById = new Map ();
93
+ for (const id of linkedStageIds) {
94
+ const s = appModel.stages.get(id);
95
+ if (s) stagesById.set(id, { submittedAt: s.submittedAt, completedAt: s.completedAt });
96
+ }
97
+ for (const [segIdx, sId] of mapSegmentsToStagesForDisplay(segments, stagesById, linkedStageIds, segmentTopology)) {
98
+ segmentStageIds.set(segIdx, sId);
99
+ }
100
+ }
101
+
102
+ // A full walk always happens first (cheap: one tree pass), splitting
103
+ // every Exchange and tagging each half's own segmentIndex correctly.
104
+ // Scope filtering is a post-pass over this output, since a split node's
105
+ // two halves can land in two different segments (see module doc comment)
106
+ // and a pre-split per-original-node filter can't express that.
107
+ const idOf = new WeakMap ();
108
+ let counter = 0;
109
+ const allNodes = [];
110
+ const allEdges = [];
111
+
112
+ walkPlanTree(planTree, (node, parent) => {
113
+ const id = `n${counter++}`;
114
+ const category = classifyNode(node.name);
115
+ // computeSegments (above) already walked this same planTree with the
116
+ // same `{ dedupe: true }` traversal, so every node this walk visits is
117
+ // guaranteed to already have a segOf entry; the `!` reflects that
118
+ // invariant, not an actual nullable field.
119
+ const seg = segOf.get(node) ;
120
+ const parentId = parent ? idOf.get(parent) : null;
121
+
122
+ if (isExchangeNode(node)) {
123
+ const readId = `${id}-read`;
124
+ const writeId = `${id}-write`;
125
+ const writeSegment = node.children?.length ? segOf.get(node.children[0]) : seg;
126
+ allNodes.push(makeGraphNode({
127
+ id: readId, sourceNodeId: id, planNode: node, category, segmentIndex: seg, splitRole: 'read',
128
+ durationShare: durationMap?.get(node),
129
+ }));
130
+ allNodes.push(makeGraphNode({
131
+ id: writeId, sourceNodeId: id, planNode: node, category, segmentIndex: writeSegment, splitRole: 'write',
132
+ }));
133
+ allEdges.push({ id: `${readId}=>${writeId}`, source: readId, target: writeId });
134
+ if (parentId) allEdges.push({ id: `${parentId}->${readId}`, source: parentId, target: readId });
135
+ idOf.set(node, writeId);
136
+ } else {
137
+ allNodes.push(makeGraphNode({
138
+ id, sourceNodeId: id, planNode: node, category, segmentIndex: seg, splitRole: null,
139
+ durationShare: durationMap?.get(node),
140
+ }));
141
+ if (parentId) allEdges.push({ id: `${parentId}->${id}`, source: parentId, target: id });
142
+ idOf.set(node, id);
143
+ }
144
+ }, { dedupe: true });
145
+
146
+ if (scope === 'full') {
147
+ return { nodes: allNodes, edges: allEdges, segmentIndex: null, segmentCount, scope: 'full', segmentStageIds };
148
+ }
149
+
150
+ let targetSegmentIndex = null;
151
+ for (const [segIdx, sId] of segmentStageIds) {
152
+ if (sId === stageId) { targetSegmentIndex = segIdx; break; }
153
+ }
154
+
155
+ if (targetSegmentIndex === null) {
156
+ // Segment lookup failed (no sql/stage linkage, or this stage's segment
157
+ // fell outside the Math.min(segments, stages) truncation), fall back
158
+ // to full-plan scope rather than showing nothing.
159
+ return { nodes: allNodes, edges: allEdges, segmentIndex: null, segmentCount, scope: 'full', segmentStageIds };
160
+ }
161
+
162
+ const nodeIds = new Set ();
163
+ const nodes = allNodes.filter((n) => {
164
+ if (n.segmentIndex !== targetSegmentIndex) return false;
165
+ nodeIds.add(n.id);
166
+ return true;
167
+ });
168
+ const edges = allEdges.filter((e) => nodeIds.has(e.source) && nodeIds.has(e.target));
169
+
170
+ return { nodes, edges, segmentIndex: targetSegmentIndex, segmentCount, scope: 'segment', segmentStageIds };
171
+ }