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.
- package/bin/sparkforensics-analyze.mjs +288 -0
- package/package.json +29 -0
- package/vendor-core/analyzer.js +167 -0
- package/vendor-core/assert-never.js +3 -0
- package/vendor-core/cli/budgets.js +203 -0
- package/vendor-core/cli/collect-run.js +107 -0
- package/vendor-core/core-count.js +68 -0
- package/vendor-core/core-locality-ratio.js +54 -0
- package/vendor-core/core-time-series.js +92 -0
- package/vendor-core/core-usage-locality.js +70 -0
- package/vendor-core/detectors.js +1989 -0
- package/vendor-core/docs-config.js +72 -0
- package/vendor-core/docs-site-config.js +23 -0
- package/vendor-core/efficiency-model.js +62 -0
- package/vendor-core/etl-phases.js +28 -0
- package/vendor-core/event-handlers.js +906 -0
- package/vendor-core/event-schemas.js +405 -0
- package/vendor-core/evidence-availability.js +121 -0
- package/vendor-core/evidence-report.js +459 -0
- package/vendor-core/finding-action-label.js +97 -0
- package/vendor-core/finding-filter-predicate.js +38 -0
- package/vendor-core/format-utils.js +167 -0
- package/vendor-core/impact-band.js +50 -0
- package/vendor-core/impact-estimator.js +428 -0
- package/vendor-core/ingest.js +139 -0
- package/vendor-core/job-groups.js +30 -0
- package/vendor-core/load-vendored.js +24 -0
- package/vendor-core/lz4-block.js +135 -0
- package/vendor-core/mcp-error.js +3 -0
- package/vendor-core/mcp-server-factory.js +115 -0
- package/vendor-core/mcp-tools.js +331 -0
- package/vendor-core/model-assembler.js +76 -0
- package/vendor-core/occupancy.js +202 -0
- package/vendor-core/parser-worker.js +249 -0
- package/vendor-core/plan-dot.js +25 -0
- package/vendor-core/plan-duration-attribution.js +185 -0
- package/vendor-core/plan-graph-model.js +171 -0
- package/vendor-core/plan-node-detail.js +159 -0
- package/vendor-core/plan-summary.js +233 -0
- package/vendor-core/plan-tree-walk.js +29 -0
- package/vendor-core/proxy.js +157 -0
- package/vendor-core/recommendation-rollup.js +197 -0
- package/vendor-core/redact.js +175 -0
- package/vendor-core/rolling-log-reassembly.js +52 -0
- package/vendor-core/run-aggregates.js +44 -0
- package/vendor-core/run-comparison.js +458 -0
- package/vendor-core/scaling-sim.js +73 -0
- package/vendor-core/session-snapshot.js +79 -0
- package/vendor-core/shs-fetch.js +196 -0
- package/vendor-core/shs-load.js +121 -0
- package/vendor-core/shs-request.js +101 -0
- package/vendor-core/shs-schemas.js +13 -0
- package/vendor-core/snappy-block.js +140 -0
- package/vendor-core/stage-quantiles.js +199 -0
- package/vendor-core/threshold-summary.js +35 -0
- package/vendor-core/types.js +286 -0
- package/vendor-core/vendor/fflate.js +2695 -0
- package/vendor-core/vendor/fzstd.js +768 -0
- package/vendor-core/wall-clock.js +36 -0
- package/vendor-core/wasted-core-hours.js +68 -0
|
@@ -0,0 +1,906 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import {
|
|
3
|
+
SparkEventSchema,
|
|
4
|
+
ApplicationStartEventSchema,
|
|
5
|
+
EnvironmentUpdateEventSchema,
|
|
6
|
+
JobStartEventSchema,
|
|
7
|
+
JobEndEventSchema,
|
|
8
|
+
StageSubmittedEventSchema,
|
|
9
|
+
StageExecutorMetricsEventSchema,
|
|
10
|
+
TaskEndEventSchema,
|
|
11
|
+
SqlExecutionStartEventSchema,
|
|
12
|
+
SqlAdaptiveExecutionUpdateEventSchema,
|
|
13
|
+
SqlExecutionEndEventSchema,
|
|
14
|
+
DriverAccumUpdatesEventSchema,
|
|
15
|
+
ExecutorAddedEventSchema,
|
|
16
|
+
ExecutorRemovedEventSchema,
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
} from './event-schemas.js';
|
|
20
|
+
import { assertNever } from './assert-never.js';
|
|
21
|
+
import { finalizeStage } from './stage-quantiles.js';
|
|
22
|
+
import { computeRunAggregates } from './run-aggregates.js';
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
// Internal parser-state shapes. These describe the real runtime objects the
|
|
29
|
+
// handlers below build and mutate; they are intentionally not the same as
|
|
30
|
+
// the public AppModel types in types.ts (e.g. Stage), which describe the
|
|
31
|
+
// *posted* message shape after finalizeStage/appMessage reshape things.
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
// One accumulated task-attempt record, keyed by `<stageAttemptId>:<index>` (or
|
|
74
|
+
// a unique Symbol when the raw event has no Index). Matches accumulateTask's
|
|
75
|
+
// real built shape (event-handlers.js:169-189).
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
export function buildChunkDecoder() {
|
|
165
|
+
const decoder = new TextDecoder('utf-8');
|
|
166
|
+
let pending = '';
|
|
167
|
+
return {
|
|
168
|
+
decode(buffer ) {
|
|
169
|
+
const text = pending + decoder.decode(buffer, { stream: true });
|
|
170
|
+
const lines = text.split('\n');
|
|
171
|
+
pending = lines.pop() ?? '';
|
|
172
|
+
return lines.filter((l) => l.length > 0);
|
|
173
|
+
},
|
|
174
|
+
flush() {
|
|
175
|
+
const last = pending.trim();
|
|
176
|
+
pending = '';
|
|
177
|
+
return last ? [last] : [];
|
|
178
|
+
},
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function createState() {
|
|
183
|
+
return {
|
|
184
|
+
app: null,
|
|
185
|
+
pendingSparkVersion: null,
|
|
186
|
+
pendingConfig: null,
|
|
187
|
+
pendingResources: null,
|
|
188
|
+
stages: new Map(),
|
|
189
|
+
taskStore: new Map(),
|
|
190
|
+
sqlExecutions: new Map(),
|
|
191
|
+
stageToSqlExec: new Map(),
|
|
192
|
+
sqlExecStages: new Map(),
|
|
193
|
+
jobs: new Map(),
|
|
194
|
+
executors: { added: [], removed: [] },
|
|
195
|
+
skippedLines: 0,
|
|
196
|
+
accumState: new Map(),
|
|
197
|
+
rddInfo: new Map(),
|
|
198
|
+
taskAccumStages: new Map(),
|
|
199
|
+
evidenceInputs: {
|
|
200
|
+
environmentUpdates: 0,
|
|
201
|
+
applicationEnds: 0,
|
|
202
|
+
stageSubmissions: 0,
|
|
203
|
+
rddStorageSnapshots: 0,
|
|
204
|
+
sqlExecutions: 0,
|
|
205
|
+
resolvedSqlPlans: 0,
|
|
206
|
+
executorMetricRows: 0,
|
|
207
|
+
taskRecords: 0,
|
|
208
|
+
},
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Normalize the `Spark Properties` payload of SparkListenerEnvironmentUpdate.
|
|
213
|
+
// Modern Spark emits an object of string key→value; older logs use an array of
|
|
214
|
+
// [key, value] pairs. Both collapse to a plain string→string map. The schema
|
|
215
|
+
// (EnvironmentUpdateEventSchema) tolerates raw values that are a bare JSON
|
|
216
|
+
// number or boolean instead of a string, so those are coerced to their
|
|
217
|
+
// string form here, keeping this function's `Record<string, string>`
|
|
218
|
+
// contract intact for every downstream reader of `app.config`.
|
|
219
|
+
|
|
220
|
+
export function normalizeSparkProperties(
|
|
221
|
+
props
|
|
222
|
+
) {
|
|
223
|
+
if (Array.isArray(props)) {
|
|
224
|
+
const map = {};
|
|
225
|
+
for (const pair of props) {
|
|
226
|
+
if (Array.isArray(pair) && pair.length >= 2) map[pair[0]] = String(pair[1]);
|
|
227
|
+
}
|
|
228
|
+
return map;
|
|
229
|
+
}
|
|
230
|
+
const map = {};
|
|
231
|
+
for (const [key, value] of Object.entries(props ?? {})) {
|
|
232
|
+
map[key] = String(value);
|
|
233
|
+
}
|
|
234
|
+
return map;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Parse a Spark memory-size string to MiB. Spark's JVM-memory configs
|
|
238
|
+
// (spark.{executor,driver}.memory[Overhead]) use bytesConf(ByteUnit.MiB), so a
|
|
239
|
+
// bare number ("10") means MiB. A k/m/g/t suffix sets the unit (an optional
|
|
240
|
+
// trailing "b", as in "gb", is redundant). A lone trailing "b" ("10b") means bytes.
|
|
241
|
+
export function parseSparkMemoryMB(value ) {
|
|
242
|
+
if (value == null) return null;
|
|
243
|
+
const m = String(value).trim().toLowerCase().match(/^([\d.]+)\s*([kmgt]?)(b?)$/);
|
|
244
|
+
if (!m) return null;
|
|
245
|
+
const n = parseFloat(m[1]);
|
|
246
|
+
if (!Number.isFinite(n)) return null;
|
|
247
|
+
switch (m[2]) {
|
|
248
|
+
case 'k': return Math.round(n / 1024);
|
|
249
|
+
case 'g': return Math.round(n * 1024);
|
|
250
|
+
case 't': return Math.round(n * 1024 * 1024);
|
|
251
|
+
case 'm': return Math.round(n);
|
|
252
|
+
default: return m[3] === 'b' ? Math.round(n / (1024 * 1024)) : Math.round(n);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Derive an allocated-resource summary (executor/driver memory + cores, plus
|
|
257
|
+
// dynamic-allocation / shuffle-service flags) from the Spark config map.
|
|
258
|
+
// Absent keys degrade to null rather than guessed defaults.
|
|
259
|
+
export function extractResources(config ) {
|
|
260
|
+
const cfg = config ?? {};
|
|
261
|
+
const int = (k ) => {
|
|
262
|
+
if (cfg[k] == null) return null;
|
|
263
|
+
const n = parseInt(cfg[k], 10);
|
|
264
|
+
return Number.isFinite(n) ? n : null;
|
|
265
|
+
};
|
|
266
|
+
const memMB = (k ) => (cfg[k] != null ? parseSparkMemoryMB(cfg[k]) : null);
|
|
267
|
+
const bool = (k ) => (cfg[k] != null ? String(cfg[k]).toLowerCase() === 'true' : null);
|
|
268
|
+
return {
|
|
269
|
+
executor: {
|
|
270
|
+
memory: cfg['spark.executor.memory'] ?? null,
|
|
271
|
+
memoryMB: memMB('spark.executor.memory'),
|
|
272
|
+
memoryOverhead: cfg['spark.executor.memoryOverhead'] ?? null,
|
|
273
|
+
memoryOverheadMB: memMB('spark.executor.memoryOverhead'),
|
|
274
|
+
cores: int('spark.executor.cores'),
|
|
275
|
+
instances: int('spark.executor.instances'),
|
|
276
|
+
},
|
|
277
|
+
driver: {
|
|
278
|
+
memory: cfg['spark.driver.memory'] ?? null,
|
|
279
|
+
memoryMB: memMB('spark.driver.memory'),
|
|
280
|
+
memoryOverhead: cfg['spark.driver.memoryOverhead'] ?? null,
|
|
281
|
+
memoryOverheadMB: memMB('spark.driver.memoryOverhead'),
|
|
282
|
+
cores: int('spark.driver.cores'),
|
|
283
|
+
},
|
|
284
|
+
dynamicAllocationEnabled: bool('spark.dynamicAllocation.enabled'),
|
|
285
|
+
shuffleServiceEnabled: bool('spark.shuffle.service.enabled'),
|
|
286
|
+
serializer: cfg['spark.serializer'] ?? null,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Convert the live rddInfo Map (whose entries carry a mutable `stageIds` Set
|
|
291
|
+
// used for bookkeeping across StageSubmitted events) into a postable copy
|
|
292
|
+
// with `stageIds` as a sorted array, used at every 'app' message post site
|
|
293
|
+
// so the main thread never receives a live Set it can't structured-clone
|
|
294
|
+
// meaningfully or that could keep mutating after being posted.
|
|
295
|
+
function snapshotRddInfo(rddInfo ) {
|
|
296
|
+
const out = new Map ();
|
|
297
|
+
for (const [id, r] of rddInfo) {
|
|
298
|
+
out.set(id, { ...r, stageIds: [...r.stageIds].sort((a, b) => a - b) });
|
|
299
|
+
}
|
|
300
|
+
return out;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function snapshotEvidenceInputs(state ) {
|
|
304
|
+
return { ...state.evidenceInputs };
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Snapshots the current evidence counters and posts an `app` message. This is
|
|
309
|
+
* called from `SparkListenerApplicationStart`, `SparkListenerEnvironmentUpdate`,
|
|
310
|
+
* and `SparkListenerApplicationEnd`, so every streaming `app` message carries a
|
|
311
|
+
* counter snapshot taken at that moment. Only the terminal snapshot emitted by
|
|
312
|
+
* `emitParseCompletion` is authoritative; mid-parse snapshots are partial and
|
|
313
|
+
* must not be used for final evidence-availability conclusions.
|
|
314
|
+
*
|
|
315
|
+
* Only ever called once `state.app` has been assigned (from startApplication,
|
|
316
|
+
* or from updateEnvironment/the ApplicationEnd case which both guard on
|
|
317
|
+
* `state.app` truthiness before calling this), hence the non-null assertion.
|
|
318
|
+
*/
|
|
319
|
+
function appMessage(state ) {
|
|
320
|
+
const evidenceInputs = snapshotEvidenceInputs(state);
|
|
321
|
+
state.app .evidenceInputs = evidenceInputs;
|
|
322
|
+
return {
|
|
323
|
+
type: 'app',
|
|
324
|
+
data: { ...state.app , rddInfo: snapshotRddInfo(state.rddInfo) },
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
export function accumulateTask(event , state ) {
|
|
329
|
+
const stageId = event['Stage ID'];
|
|
330
|
+
const stage = state.stages.get(stageId);
|
|
331
|
+
if (!stage) return null;
|
|
332
|
+
// Late TaskEnd for a stage whose StageCompleted already arrived and freed
|
|
333
|
+
// taskAttempts (finalizeStage), same out-of-order tolerance as
|
|
334
|
+
// SparkListenerStageExecutorMetrics above; the task's stats are already
|
|
335
|
+
// baked into the finalized stage and don't need to be re-added.
|
|
336
|
+
if (stage.taskAttempts === null) return null;
|
|
337
|
+
|
|
338
|
+
state.evidenceInputs.taskRecords++;
|
|
339
|
+
|
|
340
|
+
const accumulables = event['Task Info']?.Accumulables ?? [];
|
|
341
|
+
for (const acc of accumulables) {
|
|
342
|
+
if (!state.taskAccumStages.has(acc.ID)) state.taskAccumStages.set(acc.ID, new Set());
|
|
343
|
+
state.taskAccumStages.get(acc.ID) .add(stageId);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// 'Task Info' itself and its Failed/Killed/Speculative fields are all
|
|
347
|
+
// optional in the schema (real event-log variants omit them); Partial<>
|
|
348
|
+
// lets the {} fallback (an absent 'Task Info') type-check while every
|
|
349
|
+
// read below still defaults via `??`/`||`.
|
|
350
|
+
|
|
351
|
+
const info = event['Task Info'] ?? {};
|
|
352
|
+
const m = event['Task Metrics'] ?? {};
|
|
353
|
+
const sr = m['Shuffle Read Metrics'] ?? {};
|
|
354
|
+
const sw = m['Shuffle Write Metrics'] ?? {};
|
|
355
|
+
const inp = m['Input Metrics'] ?? {};
|
|
356
|
+
const out = m['Output Metrics'] ?? {};
|
|
357
|
+
|
|
358
|
+
const duration = (info['Finish Time'] ?? 0) - (info['Launch Time'] ?? 0);
|
|
359
|
+
const failed = !!(info['Failed'] || info['Killed']);
|
|
360
|
+
|
|
361
|
+
const record = {
|
|
362
|
+
duration, failed,
|
|
363
|
+
launchTime: info['Launch Time'] ?? 0,
|
|
364
|
+
finishTime: info['Finish Time'] ?? 0,
|
|
365
|
+
reason: event['Task End Reason']?.['Reason'] ?? null,
|
|
366
|
+
speculative: info['Speculative'] === true,
|
|
367
|
+
host: info['Host'] ?? '',
|
|
368
|
+
executorId: info['Executor ID'] ?? '',
|
|
369
|
+
locality: info['Locality'] ?? null,
|
|
370
|
+
peakExecMem: m['Peak Execution Memory'] ?? 0,
|
|
371
|
+
gcTime: m['JVM GC Time'] ?? 0,
|
|
372
|
+
memSpilled: m['Memory Bytes Spilled'] ?? 0,
|
|
373
|
+
diskSpilled: m['Disk Bytes Spilled'] ?? 0,
|
|
374
|
+
shuffleRead: (sr['Remote Bytes Read'] ?? 0) + (sr['Local Bytes Read'] ?? 0),
|
|
375
|
+
shuffleWrite: sw['Shuffle Bytes Written'] ?? 0,
|
|
376
|
+
fetchWaitTime: sr['Fetch Wait Time'] ?? 0,
|
|
377
|
+
executorRunTime: m['Executor Run Time'] ?? 0,
|
|
378
|
+
executorCpuTime: m['Executor CPU Time'] ?? 0,
|
|
379
|
+
inputBytes: inp['Bytes Read'] ?? 0,
|
|
380
|
+
outputBytes: out['Bytes Written'] ?? 0,
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
// Dedupe only when Index is present (always true for real Spark event logs).
|
|
384
|
+
// Without it, every event is treated as a distinct task: preserves legacy
|
|
385
|
+
// fixture behavior for tests that omit Index.
|
|
386
|
+
const index = info['Index'];
|
|
387
|
+
const key = index != null ? `${event['Stage Attempt ID'] ?? 0}:${index}` : Symbol('no-index');
|
|
388
|
+
const existing = stage.taskAttempts.get(key);
|
|
389
|
+
|
|
390
|
+
if (!existing) {
|
|
391
|
+
stage.taskAttempts.set(key, record);
|
|
392
|
+
} else if (existing.failed && !record.failed) {
|
|
393
|
+
// A retry succeeded where the earlier attempt failed: the earlier
|
|
394
|
+
// attempt's time was wasted work, not a metric to fold twice.
|
|
395
|
+
//
|
|
396
|
+
// Cause classification: Spark only marks the speculative COPY's own
|
|
397
|
+
// TaskInfo.Speculative as true, never the original it raced against,
|
|
398
|
+
// so checking either side of the comparison (not just the discarded
|
|
399
|
+
// record) is required to catch both "original loses to its speculative
|
|
400
|
+
// twin" and "speculative twin loses to the original" symmetrically,
|
|
401
|
+
// regardless of arrival order.
|
|
402
|
+
if (existing.speculative || record.speculative) {
|
|
403
|
+
stage.speculationWasteMs += existing.duration;
|
|
404
|
+
stage.speculationWastedAttempts++;
|
|
405
|
+
} else {
|
|
406
|
+
stage.retryWasteMs += existing.duration;
|
|
407
|
+
stage.wastedAttempts++;
|
|
408
|
+
}
|
|
409
|
+
stage.taskAttempts.set(key, record);
|
|
410
|
+
} else {
|
|
411
|
+
// Non-winning duplicate (both failed, or a race where a winner is
|
|
412
|
+
// already recorded): its time is waste, its metrics are discarded.
|
|
413
|
+
if (existing.speculative || record.speculative) {
|
|
414
|
+
stage.speculationWasteMs += record.duration;
|
|
415
|
+
stage.speculationWastedAttempts++;
|
|
416
|
+
} else {
|
|
417
|
+
stage.retryWasteMs += record.duration;
|
|
418
|
+
stage.wastedAttempts++;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
return null;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
export function resolvePlanTree(
|
|
426
|
+
rootInfo ,
|
|
427
|
+
accumMap ,
|
|
428
|
+
taskAccumStages ,
|
|
429
|
+
executionStageIds ,
|
|
430
|
+
) {
|
|
431
|
+
const seen = new WeakSet ();
|
|
432
|
+
const nodeMap = new WeakMap (); // sparkPlanInfoNode → resolved PlanNode
|
|
433
|
+
|
|
434
|
+
function makeNode(info ) {
|
|
435
|
+
const metrics = (info.metrics ?? []).reduce ((acc, m) => {
|
|
436
|
+
if (m.accumulatorId !== undefined && accumMap.has(m.accumulatorId)) {
|
|
437
|
+
acc.push({ name: m.name, value: accumMap.get(m.accumulatorId) , metricType: m.metricType });
|
|
438
|
+
}
|
|
439
|
+
return acc;
|
|
440
|
+
}, []);
|
|
441
|
+
|
|
442
|
+
// Union the stages where this node's accumulator IDs were actually
|
|
443
|
+
// observed running (Task 2's data), then clip to this SQL execution's own
|
|
444
|
+
// stage universe (Task 3's data): an accumulator ID can occasionally
|
|
445
|
+
// belong to a different execution's stages (e.g. a reused subquery),
|
|
446
|
+
// and without the clip that would misattribute another execution's work.
|
|
447
|
+
const stageIdSet = new Set ();
|
|
448
|
+
for (const m of info.metrics ?? []) {
|
|
449
|
+
if (m.accumulatorId === undefined) continue;
|
|
450
|
+
const stages = taskAccumStages.get(m.accumulatorId);
|
|
451
|
+
if (!stages) continue;
|
|
452
|
+
for (const sid of stages) {
|
|
453
|
+
if (executionStageIds && executionStageIds.has(sid)) stageIdSet.add(sid);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
const node = { name: info.nodeName, detail: info.simpleString ?? '', metrics, children: [] };
|
|
458
|
+
if (stageIdSet.size > 0) node.stageIds = [...stageIdSet].sort((a, b) => a - b);
|
|
459
|
+
return node;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// DFS to collect nodes in discovery order
|
|
463
|
+
const stack = [rootInfo];
|
|
464
|
+
const order = [];
|
|
465
|
+
|
|
466
|
+
while (stack.length > 0) {
|
|
467
|
+
const info = stack.pop() ;
|
|
468
|
+
if (seen.has(info)) continue;
|
|
469
|
+
seen.add(info);
|
|
470
|
+
order.push(info);
|
|
471
|
+
for (const child of (info.children ?? [])) {
|
|
472
|
+
if (!seen.has(child)) stack.push(child);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// Build nodes bottom-up (reverse discovery = children before parents)
|
|
477
|
+
for (let i = order.length - 1; i >= 0; i--) {
|
|
478
|
+
const info = order[i];
|
|
479
|
+
const node = makeNode(info);
|
|
480
|
+
nodeMap.set(info, node);
|
|
481
|
+
for (const child of (info.children ?? [])) {
|
|
482
|
+
const childNode = nodeMap.get(child);
|
|
483
|
+
if (childNode) node.children.push(childNode);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
return nodeMap.get(rootInfo) ;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// Maps SparkListenerStageExecutorMetrics' "Executor Metrics" field names
|
|
491
|
+
// (Spark's ExecutorMetricType constants) to our camelCase data-model names.
|
|
492
|
+
// Only listed fields are captured: anything else is ignored (forward-compat
|
|
493
|
+
// with future Spark ExecutorMetricType additions).
|
|
494
|
+
const EXECUTOR_METRIC_FIELD_MAP = {
|
|
495
|
+
JVMHeapMemory: 'jvmHeapMemory', JVMOffHeapMemory: 'jvmOffHeapMemory',
|
|
496
|
+
OnHeapExecutionMemory: 'onHeapExecutionMemory', OffHeapExecutionMemory: 'offHeapExecutionMemory',
|
|
497
|
+
OnHeapStorageMemory: 'onHeapStorageMemory', OffHeapStorageMemory: 'offHeapStorageMemory',
|
|
498
|
+
OnHeapUnifiedMemory: 'onHeapUnifiedMemory', OffHeapUnifiedMemory: 'offHeapUnifiedMemory',
|
|
499
|
+
DirectPoolMemory: 'directPoolMemory', MappedPoolMemory: 'mappedPoolMemory',
|
|
500
|
+
ProcessTreeJVMVMemory: 'processTreeJVMVMemory', ProcessTreeJVMRSSMemory: 'processTreeJVMRSSMemory',
|
|
501
|
+
ProcessTreePythonVMemory: 'processTreePythonVMemory', ProcessTreePythonRSSMemory: 'processTreePythonRSSMemory',
|
|
502
|
+
ProcessTreeOtherVMemory: 'processTreeOtherVMemory', ProcessTreeOtherRSSMemory: 'processTreeOtherRSSMemory',
|
|
503
|
+
MinorGCCount: 'minorGCCount', MinorGCTime: 'minorGCTime',
|
|
504
|
+
MajorGCCount: 'majorGCCount', MajorGCTime: 'majorGCTime', TotalGCTime: 'totalGCTime',
|
|
505
|
+
ConcurrentGCCount: 'concurrentGCCount', ConcurrentGCTime: 'concurrentGCTime',
|
|
506
|
+
};
|
|
507
|
+
|
|
508
|
+
export function startApplication(event , state ) {
|
|
509
|
+
const config = state.pendingConfig ?? {};
|
|
510
|
+
state.app = {
|
|
511
|
+
id: event['App ID'],
|
|
512
|
+
name: event['App Name'],
|
|
513
|
+
startTime: event['Timestamp'],
|
|
514
|
+
endTime: null,
|
|
515
|
+
sparkVersion: state.pendingSparkVersion ?? event['Spark Version'] ?? null,
|
|
516
|
+
config,
|
|
517
|
+
resources: state.pendingResources ?? extractResources(config),
|
|
518
|
+
rddInfo: state.rddInfo,
|
|
519
|
+
};
|
|
520
|
+
return appMessage(state);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
export function updateEnvironment(event , state ) {
|
|
524
|
+
state.evidenceInputs.environmentUpdates++;
|
|
525
|
+
const config = normalizeSparkProperties(event['Spark Properties']);
|
|
526
|
+
const resources = extractResources(config);
|
|
527
|
+
// EnvironmentUpdate normally precedes ApplicationStart: stash the config
|
|
528
|
+
// so ApplicationStart can attach it. If it arrives after (a mid-run
|
|
529
|
+
// update), apply it live and re-post the app so the main thread refreshes.
|
|
530
|
+
if (state.app) {
|
|
531
|
+
state.app.config = config;
|
|
532
|
+
state.app.resources = resources;
|
|
533
|
+
return appMessage(state);
|
|
534
|
+
}
|
|
535
|
+
state.pendingConfig = config;
|
|
536
|
+
state.pendingResources = resources;
|
|
537
|
+
return null;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
export function startJob(event , state ) {
|
|
541
|
+
const stageIds = event['Stage IDs'] ?? [];
|
|
542
|
+
const sqlExecIdStr = event['Properties']?.['spark.sql.execution.id'];
|
|
543
|
+
const sqlExecutionId = sqlExecIdStr != null ? parseInt(String(sqlExecIdStr), 10) : null;
|
|
544
|
+
if (sqlExecutionId != null) {
|
|
545
|
+
if (!state.sqlExecStages.has(sqlExecutionId)) state.sqlExecStages.set(sqlExecutionId, new Set());
|
|
546
|
+
const stageSet = state.sqlExecStages.get(sqlExecutionId) ;
|
|
547
|
+
for (const stageId of stageIds) {
|
|
548
|
+
state.stageToSqlExec.set(stageId, sqlExecutionId);
|
|
549
|
+
stageSet.add(stageId);
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
const jobId = event['Job ID'];
|
|
553
|
+
if (jobId != null) {
|
|
554
|
+
state.jobs.set(jobId, {
|
|
555
|
+
id: jobId,
|
|
556
|
+
submissionTime: event['Submission Time'] ?? null,
|
|
557
|
+
stageIds,
|
|
558
|
+
sqlExecutionId,
|
|
559
|
+
result: null, succeeded: null, exception: null, completionTime: null,
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
return null;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
export function endJob(event , state ) {
|
|
566
|
+
const jobId = event['Job ID'];
|
|
567
|
+
const result = event['Job Result']?.['Result'] ?? null;
|
|
568
|
+
const exception = event['Job Result']?.['Exception']?.['Message'] ?? null;
|
|
569
|
+
const job = state.jobs.get(jobId) ?? {
|
|
570
|
+
id: jobId, submissionTime: null, stageIds: [], sqlExecutionId: null,
|
|
571
|
+
result: null, succeeded: null, exception: null, completionTime: null,
|
|
572
|
+
};
|
|
573
|
+
job.result = result;
|
|
574
|
+
job.succeeded = result === 'JobSucceeded';
|
|
575
|
+
job.exception = exception;
|
|
576
|
+
job.completionTime = event['Completion Time'] ?? null;
|
|
577
|
+
state.jobs.set(jobId, job);
|
|
578
|
+
return { type: 'job', data: { ...job } };
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
export function submitStage(event , state ) {
|
|
582
|
+
state.evidenceInputs.stageSubmissions++;
|
|
583
|
+
const info = event['Stage Info'];
|
|
584
|
+
const id = info['Stage ID'];
|
|
585
|
+
state.stages.set(id, {
|
|
586
|
+
id, name: info['Stage Name'] ?? '', details: info['Details'] ?? '',
|
|
587
|
+
submittedAt: info['Submission Time'] ?? 0, completedAt: 0,
|
|
588
|
+
taskCount: 0, failedTasks: 0,
|
|
589
|
+
shuffleReadBytes: 0, shuffleWriteBytes: 0, fetchWaitTime: 0,
|
|
590
|
+
memoryBytesSpilled: 0, diskBytesSpilled: 0,
|
|
591
|
+
jvmGCTime: 0, executorRunTime: 0, executorCpuTime: 0,
|
|
592
|
+
inputBytes: 0, outputBytes: 0,
|
|
593
|
+
sqlExecutionId: state.stageToSqlExec.get(id) ?? null,
|
|
594
|
+
parentIds: info['Parent IDs'] ?? [],
|
|
595
|
+
hostStats: new Map(),
|
|
596
|
+
speculativeTasks: 0,
|
|
597
|
+
failureReasons: new Map(),
|
|
598
|
+
stageFailureReason: null,
|
|
599
|
+
taskAttempts: new Map(),
|
|
600
|
+
retryWasteMs: 0,
|
|
601
|
+
wastedAttempts: 0,
|
|
602
|
+
speculationWasteMs: 0,
|
|
603
|
+
speculationWastedAttempts: 0,
|
|
604
|
+
executorMetrics: new Map(),
|
|
605
|
+
});
|
|
606
|
+
mergeStageRddInfo(info, id, state);
|
|
607
|
+
return null;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
export function mergeStageRddInfo(
|
|
611
|
+
info ,
|
|
612
|
+
id ,
|
|
613
|
+
state
|
|
614
|
+
) {
|
|
615
|
+
for (const rdd of (info['RDD Info'] ?? [])) {
|
|
616
|
+
const rddId = rdd['RDD ID'];
|
|
617
|
+
if (rddId == null) continue;
|
|
618
|
+
state.evidenceInputs.rddStorageSnapshots++;
|
|
619
|
+
const sl = rdd['Storage Level'] ?? {};
|
|
620
|
+
const prev = state.rddInfo.get(rddId);
|
|
621
|
+
const stageIds = prev?.stageIds ?? new Set ();
|
|
622
|
+
stageIds.add(id);
|
|
623
|
+
state.rddInfo.set(rddId, {
|
|
624
|
+
id: rddId,
|
|
625
|
+
name: rdd['Name'] ?? '',
|
|
626
|
+
callsite: rdd['Callsite'] ?? '',
|
|
627
|
+
storageLevel: {
|
|
628
|
+
useDisk: sl['Use Disk'] ?? false,
|
|
629
|
+
useMemory: sl['Use Memory'] ?? false,
|
|
630
|
+
deserialized: sl['Deserialized'] ?? false,
|
|
631
|
+
replication: sl['Replication'] ?? 1,
|
|
632
|
+
},
|
|
633
|
+
numPartitions: rdd['Number of Partitions'] ?? 0,
|
|
634
|
+
// Merge forward, don't overwrite: an RDD being cached for the first
|
|
635
|
+
// time in THIS stage legitimately reports 0 here (Spark's snapshot
|
|
636
|
+
// reflects BlockManager state at submission time). If a later
|
|
637
|
+
// resubmission arrives before the cache numbers are re-observed,
|
|
638
|
+
// keep the last real value instead of regressing to 0/em-dash.
|
|
639
|
+
numCachedPartitions: rdd['Number of Cached Partitions'] || prev?.numCachedPartitions || 0,
|
|
640
|
+
memorySize: rdd['Memory Size'] || prev?.memorySize || 0,
|
|
641
|
+
diskSize: rdd['Disk Size'] || prev?.diskSize || 0,
|
|
642
|
+
stageIds,
|
|
643
|
+
});
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
// Spark's EventLoggingListener logs these AFTER the stage's
|
|
648
|
+
// SparkListenerStageCompleted, so finalizeStage has already posted
|
|
649
|
+
// (structured-cloned) the stage with an empty executorMetrics Map by the
|
|
650
|
+
// time they arrive. We keep accumulating into the worker-side stage here,
|
|
651
|
+
// then re-post all populated maps once via `stageExecutorMetrics` just
|
|
652
|
+
// before `done` (see collectStageExecutorMetrics) so the main-thread
|
|
653
|
+
// stages are patched before analyze() runs.
|
|
654
|
+
export function recordStageExecutorMetrics(event , state ) {
|
|
655
|
+
const stage = state.stages.get(event['Stage ID']);
|
|
656
|
+
if (!stage) return null;
|
|
657
|
+
const raw = event['Executor Metrics'] ?? {};
|
|
658
|
+
const metrics = {};
|
|
659
|
+
for (const [sparkName, ourName] of Object.entries(EXECUTOR_METRIC_FIELD_MAP)) {
|
|
660
|
+
if (raw[sparkName] != null) metrics[ourName] = raw[sparkName];
|
|
661
|
+
}
|
|
662
|
+
if (Object.keys(metrics).length > 0) {
|
|
663
|
+
state.evidenceInputs.executorMetricRows++;
|
|
664
|
+
}
|
|
665
|
+
stage.executorMetrics.set(event['Executor ID'], metrics);
|
|
666
|
+
return null;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
export function startSqlExecution(event , state ) {
|
|
670
|
+
state.evidenceInputs.sqlExecutions++;
|
|
671
|
+
const sparkPlanInfo = event.sparkPlanInfo ?? null;
|
|
672
|
+
const exec = {
|
|
673
|
+
id: event.executionId, description: event.description ?? '',
|
|
674
|
+
startTime: event.time, endTime: null, stageIds: [],
|
|
675
|
+
physicalPlanDescription: event.physicalPlanDescription ?? '',
|
|
676
|
+
sparkPlanInfo,
|
|
677
|
+
hadAdaptiveUpdate: false,
|
|
678
|
+
};
|
|
679
|
+
state.sqlExecutions.set(exec.id, exec);
|
|
680
|
+
if (sparkPlanInfo !== null) {
|
|
681
|
+
state.accumState.set(exec.id, new Map());
|
|
682
|
+
}
|
|
683
|
+
return { type: 'sql', data: exec };
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
export function endSqlExecution(
|
|
687
|
+
event ,
|
|
688
|
+
state
|
|
689
|
+
) {
|
|
690
|
+
const exec = state.sqlExecutions.get(event.executionId);
|
|
691
|
+
if (exec) exec.endTime = event.time;
|
|
692
|
+
|
|
693
|
+
const planInfo = exec?.sparkPlanInfo ?? null;
|
|
694
|
+
if (!planInfo || !planInfo.nodeName) {
|
|
695
|
+
state.accumState.delete(event.executionId);
|
|
696
|
+
return exec ? { type: 'sql', data: { ...exec } } : null;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
const accumMap = state.accumState.get(event.executionId) ?? new Map ();
|
|
700
|
+
const planTree = resolvePlanTree(planInfo, accumMap, state.taskAccumStages, state.sqlExecStages.get(event.executionId));
|
|
701
|
+
state.accumState.delete(event.executionId);
|
|
702
|
+
|
|
703
|
+
state.evidenceInputs.resolvedSqlPlans++;
|
|
704
|
+
return { type: 'sqlPlan', data: { executionId: event.executionId, planTree } };
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
export function applyDriverAccumUpdates(event , state ) {
|
|
708
|
+
const { executionId, accumUpdates } = event;
|
|
709
|
+
if (!state.accumState.has(executionId)) return null; // late update, discard
|
|
710
|
+
const map = state.accumState.get(executionId) ;
|
|
711
|
+
for (const [accId, delta] of accumUpdates) {
|
|
712
|
+
map.set(accId, (map.get(accId) ?? 0) + delta);
|
|
713
|
+
}
|
|
714
|
+
return null;
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
export function applyAdaptiveExecutionUpdate(
|
|
718
|
+
event ,
|
|
719
|
+
state ,
|
|
720
|
+
) {
|
|
721
|
+
const exec = state.sqlExecutions.get(event.executionId);
|
|
722
|
+
if (!exec) return null; // late update for an unseen execution, discard (same pattern as applyDriverAccumUpdates)
|
|
723
|
+
if (event.sparkPlanInfo != null) exec.sparkPlanInfo = event.sparkPlanInfo;
|
|
724
|
+
if (event.physicalPlanDescription != null) exec.physicalPlanDescription = event.physicalPlanDescription;
|
|
725
|
+
exec.hadAdaptiveUpdate = true;
|
|
726
|
+
// Re-emit a 'sql' message (same shape startSqlExecution posts) so the
|
|
727
|
+
// browser's structured-cloned appModel.sql copy actually sees the flip:
|
|
728
|
+
// without this, hadAdaptiveUpdate only ever reads true via collectRun's
|
|
729
|
+
// Node-path object aliasing (no postMessage clone in that path), never in
|
|
730
|
+
// the shipping worker. Shallow copy so the posted object isn't the same
|
|
731
|
+
// mutable reference the worker keeps mutating (mirrors endSqlExecution's
|
|
732
|
+
// `{ ...exec }` pattern above).
|
|
733
|
+
return { type: 'sql', data: { ...exec } };
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
export function addExecutor(event , state ) {
|
|
737
|
+
const ev = {
|
|
738
|
+
kind: 'added', timestamp: event['Timestamp'],
|
|
739
|
+
executorId: event['Executor ID'],
|
|
740
|
+
host: event['Executor Info']?.['Host'] ?? '',
|
|
741
|
+
totalCores: event['Executor Info']?.['Total Cores'] ?? 0,
|
|
742
|
+
// Resource Profile Id lives inside Executor Info in real Spark logs;
|
|
743
|
+
// fall back to a top-level key for any variant that hoists it.
|
|
744
|
+
resourceProfileId: event['Executor Info']?.['Resource Profile Id'] ?? event['Resource Profile Id'] ?? null,
|
|
745
|
+
};
|
|
746
|
+
state.executors.added.push(ev);
|
|
747
|
+
return { type: 'executor', data: ev };
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
export function removeExecutor(event , state ) {
|
|
751
|
+
const ev = {
|
|
752
|
+
kind: 'removed', timestamp: event['Timestamp'],
|
|
753
|
+
executorId: event['Executor ID'],
|
|
754
|
+
reason: event['Removed Reason'] ?? '',
|
|
755
|
+
};
|
|
756
|
+
state.executors.removed.push(ev);
|
|
757
|
+
return { type: 'executor', data: ev };
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
export function processEvent(event , state ) {
|
|
761
|
+
switch (event.Event) {
|
|
762
|
+
case 'SparkListenerLogStart':
|
|
763
|
+
state.pendingSparkVersion = event['Spark Version'] ?? null;
|
|
764
|
+
return null;
|
|
765
|
+
|
|
766
|
+
case 'SparkListenerApplicationStart':
|
|
767
|
+
return startApplication(event, state);
|
|
768
|
+
|
|
769
|
+
case 'SparkListenerEnvironmentUpdate':
|
|
770
|
+
return updateEnvironment(event, state);
|
|
771
|
+
|
|
772
|
+
case 'SparkListenerApplicationEnd':
|
|
773
|
+
state.evidenceInputs.applicationEnds++;
|
|
774
|
+
if (state.app) {
|
|
775
|
+
state.app.endTime = event['Timestamp'];
|
|
776
|
+
return appMessage(state);
|
|
777
|
+
}
|
|
778
|
+
return null;
|
|
779
|
+
|
|
780
|
+
case 'SparkListenerJobStart':
|
|
781
|
+
return startJob(event, state);
|
|
782
|
+
|
|
783
|
+
case 'SparkListenerJobEnd':
|
|
784
|
+
return endJob(event, state);
|
|
785
|
+
|
|
786
|
+
case 'SparkListenerStageSubmitted':
|
|
787
|
+
return submitStage(event, state);
|
|
788
|
+
|
|
789
|
+
case 'SparkListenerStageCompleted': {
|
|
790
|
+
const info = event['Stage Info'];
|
|
791
|
+
const id = info['Stage ID'];
|
|
792
|
+
const stage = state.stages.get(id);
|
|
793
|
+
if (!stage) return null;
|
|
794
|
+
stage.completedAt = info['Completion Time'] ?? 0;
|
|
795
|
+
stage.stageFailureReason = info['Failure Reason'] ?? null;
|
|
796
|
+
// finalizeStage (Task 5, stage-quantiles.ts) deliberately keeps its
|
|
797
|
+
// `stage` parameter typed as a loose Record (see that module's own
|
|
798
|
+
// comment); StageRecord's real shape (more precise than that loose
|
|
799
|
+
// type, e.g. taskAttempts values carry string/boolean fields too, not
|
|
800
|
+
// just numbers) is bridged across that boundary with an explicit cast
|
|
801
|
+
// rather than re-litigating finalizeStage's own established signature.
|
|
802
|
+
return finalizeStage(
|
|
803
|
+
id,
|
|
804
|
+
stage ,
|
|
805
|
+
state
|
|
806
|
+
);
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
case 'SparkListenerStageExecutorMetrics':
|
|
810
|
+
return recordStageExecutorMetrics(event, state);
|
|
811
|
+
|
|
812
|
+
case 'SparkListenerTaskEnd':
|
|
813
|
+
return accumulateTask(event, state);
|
|
814
|
+
|
|
815
|
+
case 'org.apache.spark.sql.execution.ui.SparkListenerSQLExecutionStart':
|
|
816
|
+
return startSqlExecution(event, state);
|
|
817
|
+
|
|
818
|
+
case 'org.apache.spark.sql.execution.ui.SparkListenerSQLAdaptiveExecutionUpdate':
|
|
819
|
+
return applyAdaptiveExecutionUpdate(event, state);
|
|
820
|
+
|
|
821
|
+
case 'org.apache.spark.sql.execution.ui.SparkListenerSQLExecutionEnd':
|
|
822
|
+
return endSqlExecution(event, state);
|
|
823
|
+
|
|
824
|
+
case 'org.apache.spark.sql.execution.ui.SparkListenerDriverAccumUpdates':
|
|
825
|
+
return applyDriverAccumUpdates(event, state);
|
|
826
|
+
|
|
827
|
+
case 'SparkListenerExecutorAdded':
|
|
828
|
+
return addExecutor(event, state);
|
|
829
|
+
|
|
830
|
+
case 'SparkListenerExecutorRemoved':
|
|
831
|
+
return removeExecutor(event, state);
|
|
832
|
+
|
|
833
|
+
default:
|
|
834
|
+
return assertNever(event);
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
// Derived (not hand-maintained) from SparkEventSchema itself, so this set
|
|
839
|
+
// can never drift from the discriminated union's real literal list.
|
|
840
|
+
const KNOWN_EVENT_TYPES = new Set(
|
|
841
|
+
SparkEventSchema.options.map((option) => option.shape.Event.value)
|
|
842
|
+
);
|
|
843
|
+
|
|
844
|
+
export function dispatchLine(line , state , emit ) {
|
|
845
|
+
let parsed ;
|
|
846
|
+
try {
|
|
847
|
+
parsed = JSON.parse(line);
|
|
848
|
+
} catch {
|
|
849
|
+
state.skippedLines++;
|
|
850
|
+
return;
|
|
851
|
+
}
|
|
852
|
+
// A real Spark event log carries many event types this tool has never
|
|
853
|
+
// modeled (BlockManagerAdded, TaskStart, ExecutorMetricsUpdate, ...): that
|
|
854
|
+
// was always true pre-migration and is completely benign, so an `Event`
|
|
855
|
+
// value outside our 15 modeled literals is silently ignored exactly like
|
|
856
|
+
// before, never counted toward skippedLines. Only a value that IS one of
|
|
857
|
+
// the 15 known types but fails ITS schema is a genuine, valuable signal
|
|
858
|
+
// worth surfacing as a skipped line.
|
|
859
|
+
const eventType = (parsed )?.Event;
|
|
860
|
+
if (typeof eventType !== 'string' || !KNOWN_EVENT_TYPES.has(eventType)) {
|
|
861
|
+
return;
|
|
862
|
+
}
|
|
863
|
+
const result = SparkEventSchema.safeParse(parsed);
|
|
864
|
+
if (!result.success) {
|
|
865
|
+
state.skippedLines++;
|
|
866
|
+
return;
|
|
867
|
+
}
|
|
868
|
+
let msg ;
|
|
869
|
+
try {
|
|
870
|
+
msg = processEvent(result.data, state);
|
|
871
|
+
} catch (err) {
|
|
872
|
+
// processEvent's switch is exhaustive over the schema-validated union
|
|
873
|
+
// (assertNever in the default case), so this should be unreachable for
|
|
874
|
+
// any event that made it this far. If it ever fires, it's a genuine bug
|
|
875
|
+
// in a handler, not malformed input; log it distinctly so it doesn't
|
|
876
|
+
// masquerade as an ordinary skipped-line (malformed/invalid data) count.
|
|
877
|
+
console.error('processEvent threw for an already-validated event:', err);
|
|
878
|
+
state.skippedLines++;
|
|
879
|
+
return;
|
|
880
|
+
}
|
|
881
|
+
if (msg) emit(msg);
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
// Gather every stage's post-completion-populated executorMetrics for a single
|
|
885
|
+
// re-post just before `done` (see the SparkListenerStageExecutorMetrics case
|
|
886
|
+
// for why the per-stage message posted at completion is empty). Returns a
|
|
887
|
+
// Map<stageId, Map<execId, metrics>>; empty when the log had no
|
|
888
|
+
// spark.eventLog.logStageExecutorMetrics data.
|
|
889
|
+
export function collectStageExecutorMetrics(state ) {
|
|
890
|
+
const out = new Map ();
|
|
891
|
+
for (const [id, stage] of state.stages) {
|
|
892
|
+
if (stage.executorMetrics instanceof Map && stage.executorMetrics.size > 0) {
|
|
893
|
+
out.set(id, stage.executorMetrics);
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
return out;
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
export function emitParseCompletion(state , emit , linesProcessed ) {
|
|
900
|
+
emit({ type: 'progress', pct: 1, linesProcessed });
|
|
901
|
+
emit({ type: 'runAggregates', data: computeRunAggregates(state.taskStore) });
|
|
902
|
+
emit({ type: 'stageExecutorMetrics', data: collectStageExecutorMetrics(state) });
|
|
903
|
+
emit(appMessage(state));
|
|
904
|
+
emit({ type: 'done', skippedLines: state.skippedLines });
|
|
905
|
+
state.accumState.clear();
|
|
906
|
+
}
|