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,1989 @@
1
+ import { pathBasename, formatBytes, nsToMs, IMPACT_BAND_ORDER } from './format-utils.js';
2
+ import { scanRelationId } from './plan-summary.js';
3
+ import { computeTotalCores } from './core-count.js';
4
+ import { walkPlanTree } from './plan-tree-walk.js';
5
+ import { computeCoreLocalityRatio } from './core-locality-ratio.js';
6
+ import { estimateSingleStage, } from './occupancy.js';
7
+
8
+
9
+ const MB = 1024 * 1024;
10
+ const GB = 1024 * MB;
11
+ const TB = 1024 * GB;
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // Local runtime shapes.
15
+ //
16
+ // `types.ts`'s `Stage`/`SqlExecution`/`SparkAppInfo`/`AppModel` describe the
17
+ // *posted* AppModel surface at a level of detail that suits the view layer
18
+ // (both carry a catch-all `[key: string]: unknown` index signature for
19
+ // anything beyond their few named fields). This file needs the FULL set of
20
+ // fields `finalizeStage` (src/stage-quantiles.ts) actually computes and
21
+ // `event-handlers.ts`'s stage/sql/app records actually carry, accessed
22
+ // directly (arithmetic, comparisons) rather than read-and-display, so an
23
+ // index-signature-shaped type would force an `unknown` cast at nearly every
24
+ // field access. Every field below is verified against a real access in this
25
+ // file (or a helper it calls); none are speculative.
26
+ //
27
+ // `analyzer.js` (the only real caller of `detect()`, still plain JS) passes
28
+ // whatever the parser actually produced, so these types describe reality,
29
+ // not a narrowing of some existing stricter type; there is nothing unsound
30
+ // about them being independent of `types.ts`'s `Stage`/`SqlExecution`.
31
+
32
+
33
+
34
+
35
+
36
+
37
+
38
+ // Per-executor snapshot from a StageExecutorMetrics event (mergeStageRddInfo/
39
+ // onStageExecutorMetrics): a loose bag of Spark's ExecutorMetrics field
40
+ // names, only a few of which any detector reads.
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
+
74
+
75
+
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
+ // The full context object analyzer.js's `analyze()` builds and passes as
126
+ // every stage/sql detect()'s second argument, and as the sole argument for
127
+ // 'app' scope (`d.detect(ctx)`). 'config' scope gets a narrower `{ app }`
128
+ // (see auditConfig in analyzer.js), typed per-entry below instead of here.
129
+ //
130
+ // `app` is non-nullable here (unlike `AppModel.app: SparkAppInfo | null`):
131
+ // `analyze()` only ever runs after a full parse, by which point `app` is
132
+ // always populated; the `SparkAppInfo | null` nullability models the
133
+ // in-progress-parse window this file never observes. This matches every
134
+ // 'app'-scope entry below: most defensively write `ctx.app?.foo` anyway
135
+ // (harmless on a non-nullable value), and `coldStart` reads `app.startTime`
136
+ // with no guard at all, which only type-checks if `app` is non-nullable.
137
+ // `auditConfig`'s separate config-scope target type keeps `app` nullable
138
+ // instead, since `auditConfig(appModel.app)` (src/analyzer.js) really can be
139
+ // called with a `null` app and every config-scope entry optional-chains it.
140
+
141
+
142
+
143
+
144
+
145
+
146
+
147
+
148
+
149
+
150
+
151
+
152
+
153
+
154
+
155
+
156
+ // `auditConfig(app)` (src/analyzer.js) calls every 'config'-scope entry's
157
+ // `detect({ app })` directly with whatever `appModel.app` is at call time
158
+ // (`SparkAppInfo | null`), independent of the full `DetectorCtx` above.
159
+
160
+
161
+
162
+
163
+
164
+
165
+
166
+
167
+
168
+
169
+
170
+ // sparkDoctor SpillPressureDetector (5a) + SpillSkewDetector (5b).
171
+ // Returns { magnitude } | null. Magnitude ∈ 'severe'|'high'|'medium'.
172
+ function computeSpillMagnitude(
173
+ stage ,
174
+ t ,
175
+ ) {
176
+ const { taskCount, spillDiskMax = 0, spillMemMax = 0, spillDiskP50 = 0, spillMemP50 = 0 } = stage;
177
+ // 5a: absolute volume, branching on task count.
178
+ if (taskCount === 1) {
179
+ if (spillDiskMax >= t.singleTaskDiskGiB * GB || spillMemMax >= t.singleTaskMemGiB * GB) return { magnitude: 'severe' };
180
+ } else {
181
+ const perTaskDisk = taskCount > 0 ? spillDiskMax / taskCount : 0; // approximation: max used as per-task proxy
182
+ if (spillDiskMax >= t.highDiskGiB * GB || perTaskDisk >= t.highTaskDiskMB * MB || spillMemMax >= t.highMemGiB * GB) return { magnitude: 'high' };
183
+ if (spillDiskMax >= t.medDiskMB * MB || spillMemMax >= t.medMemGiB * GB) return { magnitude: 'medium' };
184
+ }
185
+ // 5b: ratio+floor skew (requires enough tasks).
186
+ if (taskCount >= t.skewMinTasks) {
187
+ if (spillDiskP50 > 0 && spillDiskMax / spillDiskP50 > t.skewRatio && spillDiskMax >= t.skewDiskFloorMB * MB) return { magnitude: 'high' };
188
+ if (spillMemP50 > 0 && spillMemMax / spillMemP50 > t.skewRatio && spillMemMax >= t.skewMemFloorMB * MB) return { magnitude: 'medium' };
189
+ }
190
+ return null;
191
+ }
192
+
193
+ function pickDominantReason(reasons ) {
194
+ if (!Array.isArray(reasons) || reasons.length === 0) return null;
195
+ return [...reasons].sort((a, b) => b.count - a.count)[0].reason;
196
+ }
197
+
198
+ // Shared by every scope:'sql' detector below. `sql.get(executionId).stageIds`
199
+ // (src/model-assembler.js) is always empty because parser-worker.js never
200
+ // populates it, so stage linkage is derived the other way round, from each
201
+ // stage's own (correctly populated) `sqlExecutionId`. Parameter type is
202
+ // intentionally the minimal shape needed (not `DetectorStage`): callers
203
+ // outside this file (Topbar.tsx, plan-node-detail.ts, plan-graph-model.ts)
204
+ // pass `appModel.stages`, typed `Map<StageId, Stage>` per types.ts, which
205
+ // carries `id`/`sqlExecutionId` but not this file's fuller `DetectorStage`
206
+ // shape.
207
+ export function stageIdsForSqlExec(
208
+ executionId ,
209
+ stages ,
210
+ ) {
211
+ const out = [];
212
+ for (const s of stages.values()) if (s.sqlExecutionId === executionId) out.push(s.id);
213
+ return out;
214
+ }
215
+
216
+ // Shared by the three Plan Advisor detectors below (duplicatePlanSubtree,
217
+ // smallFiles, broadcastSizing): union the given nodes' own `stageIds`, or
218
+ // fall back to the whole execution's stage set when none of them have any
219
+ // coverage. A finding never partially blends a narrowed set with the
220
+ // execution-wide one (see docs/architecture.md's plan-node-to-stage mapping
221
+ // section). `fallback` is a param, not computed here, so callers can compute
222
+ // `stageIdsForSqlExec` once per `detect()` call and reuse it across every
223
+ // finding in that call instead of re-walking `stages` per finding.
224
+ export function unionStageIds(nodes , fallback ) {
225
+ const union = new Set ();
226
+ for (const node of nodes) for (const sid of node.stageIds ?? []) union.add(sid);
227
+ return union.size > 0 ? [...union].sort((a, b) => a - b) : fallback;
228
+ }
229
+
230
+ // Bottom-up shape computation for duplicate-subtree detection (sparkDoctor
231
+ // idea, src/detectors.js `duplicatePlanSubtree`) and for the cachingOpportunity
232
+ // composite (join/union) detector's anchor-fingerprint contract. `size` is the
233
+ // node count of the subtree rooted at each node; the default fingerprint
234
+ // encodes operator name + sorted metric *names* (never values, per spec) +
235
+ // children fingerprints in original order, so two subtrees with the same shape
236
+ // but different row counts/literals still collide, which is the point (we
237
+ // have no expr/plan/codegen IDs to strip in the first place, since `metrics`
238
+ // never carried them).
239
+ //
240
+ // `opts.includeDetail` (default false) folds `root`'s own `detail` text
241
+ // (normalized via `opts.normalizeDetail`) into ONLY `root`'s fingerprint,
242
+ // never into any recursive child call's fingerprint, regardless of `opts`.
243
+ // This is what lets a caller ask "does this specific node's own detail +
244
+ // structural shape match another node's", without descendant filter/scan
245
+ // detail (literals, paths) ever entering the comparison. The existing
246
+ // `duplicatePlanSubtree` call site passes no options: identical behavior,
247
+ // zero regression to its existing tests.
248
+
249
+
250
+ export function computePlanShapes(
251
+ root ,
252
+ opts = {},
253
+ ) {
254
+ const { includeDetail = false, normalizeDetail: normalize = (d ) => d } = opts;
255
+ const shapeOf = new WeakMap ();
256
+ const allNodes = [];
257
+ function visit(node , isRoot ) {
258
+ allNodes.push(node);
259
+ const childShapes = (node.children ?? []).map((c) => visit(c, false));
260
+ const size = 1 + childShapes.reduce((sum, c) => sum + c.size, 0);
261
+ const metricNames = (node.metrics ?? []).map((m) => m.name).sort().join(',');
262
+ const childFingerprints = childShapes.map((c) => c.fingerprint).join(',');
263
+ const fingerprint = isRoot && includeDetail
264
+ ? `${node.name}[${metricNames}]<${normalize(node.detail ?? '')}>{${childFingerprints}}`
265
+ : `${node.name}[${metricNames}]{${childFingerprints}}`;
266
+ const shape = { size, fingerprint };
267
+ shapeOf.set(node, shape);
268
+ return shape;
269
+ }
270
+ visit(root, true);
271
+ return { shapeOf, allNodes };
272
+ }
273
+
274
+ // Normalizes an anchor join/union node's own `detail` text for the
275
+ // cachingOpportunity composite detector's structural fingerprint
276
+ // (computePlanShapes's opts.includeDetail path). Strips per-analysis
277
+ // numbering noise that is never part of a join/union's logical identity:
278
+ // expr ids, plan/codegen-stage ids, and AQE's runtime BuildLeft/BuildRight
279
+ // broadcast-side choice (which can flip between executions of the logically
280
+ // identical join based on runtime stats); it then canonicalizes commutative
281
+ // equality operand order so `A.x = B.y` and `B.y = A.x` collide. Everything
282
+ // else (join type, columns, literal values) is kept: that's the
283
+ // semantically meaningful part a leaf-relation-set identity would miss.
284
+ export function normalizeDetail(detail ) {
285
+ let s = detail
286
+ .replace(/#\d+L?/g, '')
287
+ .replace(/,?\s*plan_id=\d+/g, '')
288
+ .replace(/\[codegen id\s*:\s*\d+\]/gi, '[codegen id]')
289
+ .replace(/\bBuild(Left|Right)\b/g, 'BuildSide');
290
+ s = s.replace(/([A-Za-z_][\w.]*)\s*=\s*([A-Za-z_][\w.]*)/g, (_m, l, r) => {
291
+ const [a, b] = [l, r].sort();
292
+ return `${a} = ${b}`;
293
+ });
294
+ return s;
295
+ }
296
+
297
+ const JOIN_NAME_RE = /Join/i;
298
+
299
+ // Structural operator kind for cachingOpportunity's composite detection:
300
+ // 'join' covers every Spark join physical operator (SortMergeJoin,
301
+ // BroadcastHashJoin, ShuffledHashJoin, BroadcastNestedLoopJoin, the same set
302
+ // plan-summary.js's visitJoin recognizes); 'union' is Spark's exact `Union`
303
+ // node name. CartesianProduct is deliberately excluded (out of scope, same
304
+ // as plan-summary.js's join handling).
305
+ export function planOperatorKind(name ) {
306
+ if (JOIN_NAME_RE.test(name)) return 'join';
307
+ if (name === 'Union') return 'union';
308
+ return null;
309
+ }
310
+
311
+ // Single bottom-up pass over a resolved planTree producing one composite
312
+ // candidate per join/union node, in O(n) total (see the design doc's
313
+ // "Compute cost" section; this deliberately does NOT call
314
+ // computePlanShapes per join/union node, since subtrees overlap and that
315
+ // would be O(n·k) on multi-way star/snowflake joins). For every node it
316
+ // computes the plain (detail-free) fingerprint once (identical formula to
317
+ // computePlanShapes's default path) and merges each subtree's leaf-relation
318
+ // byte map (via scanRelationId, same identity cachingOpportunity's existing
319
+ // leaf aggregation uses) bottom-up. When a node is a join/union, its anchor
320
+ // fingerprint folds only its OWN normalized detail (per computePlanShapes's
321
+ // opts.includeDetail contract), computed here inline, in O(1), from the
322
+ // node's own detail plus its already-computed child fingerprints, not via a
323
+ // nested computePlanShapes call. `ancestorNodes` (strict ancestors, root
324
+ // first) is threaded down for free via the recursion's own call stack, so
325
+ // later nested-composite dedupe (cachingOpportunity.detect()) doesn't need a
326
+ // separate tree walk to determine containment.
327
+
328
+
329
+
330
+
331
+
332
+
333
+
334
+
335
+ export function findCompositeCandidates(root ) {
336
+ const candidates = [];
337
+ const path = [];
338
+ function visit(node ) {
339
+ path.push(node);
340
+ const childResults = (node.children ?? []).map(visit);
341
+ path.pop();
342
+
343
+ const metricNames = (node.metrics ?? []).map((m) => m.name).sort().join(',');
344
+ const childFingerprints = childResults.map((r) => r.fingerprint).join(',');
345
+ const fingerprint = `${node.name}[${metricNames}]{${childFingerprints}}`;
346
+
347
+ const leafRelationBytes = new Map ();
348
+ const rid = scanRelationId(node.name ?? '', node.detail ?? '');
349
+ if (rid) {
350
+ const bytesMetric = (node.metrics ?? []).find((m) => m.name === FILES_READ_BYTES);
351
+ leafRelationBytes.set(rid, (leafRelationBytes.get(rid) ?? 0) + (bytesMetric ? bytesMetric.value : 0));
352
+ }
353
+ for (const child of childResults) {
354
+ for (const [crid, bytes] of child.leafRelationBytes) {
355
+ leafRelationBytes.set(crid, (leafRelationBytes.get(crid) ?? 0) + bytes);
356
+ }
357
+ }
358
+
359
+ const operator = planOperatorKind(node.name ?? '');
360
+ if (operator) {
361
+ candidates.push({
362
+ node,
363
+ operator,
364
+ fingerprint: `${node.name}[${metricNames}]<${normalizeDetail(node.detail ?? '')}>{${childFingerprints}}`,
365
+ leafRelationBytes: new Map(leafRelationBytes),
366
+ ancestorNodes: path.slice(),
367
+ });
368
+ }
369
+
370
+ return { fingerprint, leafRelationBytes };
371
+ }
372
+ visit(root);
373
+ return candidates;
374
+ }
375
+
376
+
377
+ // First scanned table/relation identity found in a subtree (pre-order, per
378
+ // walkPlanTree's contract), or null when the subtree touches no named
379
+ // relation. Surfaced on duplicatePlanSubtree findings (as `sampleRelation`,
380
+ // see findDuplicateSubtrees below) so a user can tell apart two same-shaped
381
+ // duplicate groups that scan different tables (real-log bug: two unrelated
382
+ // "BroadcastExchange over a Project/Filter/Scan" patterns, one per dimension
383
+ // table, produced byte-identical findings). This is best-effort/informational
384
+ // only, not a uniqueness guarantee: it's null for scan-less subtrees (JDBC/
385
+ // Kafka/LocalRelation) and can coincide when two groups share their first-
386
+ // encountered leaf. See findDuplicateSubtrees's groupIndex for the actual
387
+ // discriminator findingId() relies on.
388
+ function firstLeafRelationId(node ) {
389
+ let found = null;
390
+ walkPlanTree(node, (n) => {
391
+ if (found) return;
392
+ found = scanRelationId(n.name ?? '', n.detail ?? '');
393
+ });
394
+ return found;
395
+ }
396
+
397
+ // Groups nodes by fingerprint, keeping only groups of size >= minOccurrences
398
+ // whose shared subtree size is >= minSubtreeSize. De-overlap: processes
399
+ // candidate groups largest-subtree-first, and once a group is accepted every
400
+ // node inside each of its matched occurrences is marked "claimed" so a
401
+ // smaller, fully-nested duplicate group inside an already-accepted match is
402
+ // dropped (a 5-node duplicate should not also emit findings for its 3-node
403
+ // sub-subtrees); occurrences of a smaller group that fall OUTSIDE any
404
+ // accepted larger match still count normally.
405
+
406
+
407
+
408
+
409
+
410
+
411
+
412
+
413
+
414
+
415
+ export function findDuplicateSubtrees(
416
+ root ,
417
+ { minSubtreeSize, minOccurrences } ,
418
+ ) {
419
+ const { shapeOf, allNodes } = computePlanShapes(root);
420
+ const eligible = allNodes.filter(n => shapeOf.get(n) .size >= minSubtreeSize);
421
+
422
+ const groups = new Map ();
423
+ for (const n of eligible) {
424
+ const fp = shapeOf.get(n) .fingerprint;
425
+ if (!groups.has(fp)) groups.set(fp, []);
426
+ groups.get(fp) .push(n);
427
+ }
428
+
429
+ const candidates = [...groups.values()]
430
+ .filter(nodes => nodes.length >= minOccurrences)
431
+ .sort((a, b) => shapeOf.get(b[0]) .size - shapeOf.get(a[0]) .size);
432
+
433
+ const claimed = new WeakSet ();
434
+ const markClaimed = (node ) => {
435
+ claimed.add(node);
436
+ for (const c of (node.children ?? [])) markClaimed(c);
437
+ };
438
+
439
+ const results = [];
440
+ for (const nodes of candidates) {
441
+ const unclaimed = nodes.filter(n => !claimed.has(n));
442
+ if (unclaimed.length < minOccurrences) continue;
443
+ for (const n of unclaimed) markClaimed(n);
444
+ results.push({
445
+ rootName: unclaimed[0].name,
446
+ subtreeSize: shapeOf.get(unclaimed[0]) .size,
447
+ occurrences: unclaimed.length,
448
+ isExchangeRoot: /Exchange/i.test(unclaimed[0].name),
449
+ sampleRelation: firstLeafRelationId(unclaimed[0]),
450
+ // Deterministic position within this execution's group list. `sampleRelation`
451
+ // is best-effort (null when the subtree touches no named catalog relation, e.g.
452
+ // JDBC/Kafka/LocalRelation sources, or identical when two groups happen to share
453
+ // their first-encountered leaf) and is NOT sufficient on its own to guarantee two
454
+ // structurally-distinct groups get distinct finding ids; groupIndex is the actual
455
+ // uniqueness guarantee findingId() relies on.
456
+ groupIndex: results.length,
457
+ nodes: unclaimed,
458
+ });
459
+ }
460
+ return results;
461
+ }
462
+
463
+ // Exact metric names Spark emits: verified against a real SQLExecutionStart
464
+ // event's sparkPlanInfo in examples/big-application_1777489669889_51251_1.
465
+ // NOTE: the write-side byte metric is "written output", not "size of written
466
+ // files" as an earlier draft of this detector's spec assumed.
467
+ const FILES_READ_COUNT = 'number of files read';
468
+ const FILES_READ_BYTES = 'size of files read';
469
+ const FILES_WRITTEN_COUNT = 'number of written files';
470
+ const FILES_WRITTEN_BYTES = 'written output';
471
+
472
+ // Byte size of a join-side subtree for broadcast sizing (dataflint
473
+ // JoinToBroadcastAlert). Stops descending the instant a node carries a
474
+ // "data size" metric: that node's value already aggregates everything
475
+ // beneath it (e.g. an Exchange's "data size" already reflects everything it
476
+ // shuffled), so summing further down would double-count. Only recurses into
477
+ // children when the current node carries no such metric.
478
+ function sumBoundarySize(node ) {
479
+ const m = (node.metrics ?? []).find(x => x.name === 'data size');
480
+ if (m) return m.value;
481
+ let sum = 0;
482
+ for (const c of (node.children ?? [])) sum += sumBoundarySize(c);
483
+ return sum;
484
+ }
485
+
486
+ // Nodes that actually fed sumBoundarySize's total for this subtree: mirrors
487
+ // its recursion exactly (same "data size" metric check, same stop condition)
488
+ // so the implicated stageIds line up with the size that was actually
489
+ // compared, however deep that turns out to live.
490
+ function boundarySizeContributors(node ) {
491
+ const m = (node.metrics ?? []).find((x) => x.name === 'data size');
492
+ if (m) return [node];
493
+ const out = [];
494
+ for (const c of (node.children ?? [])) out.push(...boundarySizeContributors(c));
495
+ return out;
496
+ }
497
+
498
+ // Max/median deviation ratio over a list of {key, value}; returns the
499
+ // exceeding entry's key + ratio, or null when < 3 samples or median 0.
500
+ function maxMedianRatio(
501
+ samples ,
502
+ ) {
503
+ if (samples.length < 3) return null;
504
+ const vals = samples.map(s => s.value).sort((a, b) => a - b);
505
+ const median = vals[Math.floor(vals.length / 2)];
506
+ if (median <= 0) return null;
507
+ const top = samples.reduce((a, b) => (b.value > a.value ? b : a));
508
+ return { key: top.key, ratio: top.value / median, value: top.value };
509
+ }
510
+
511
+
512
+
513
+
514
+
515
+
516
+
517
+
518
+
519
+ // Machine-readable detector metadata for the evidence report: type, version,
520
+ // scope, thresholds, and doc anchor per entry (no `detect` closure). Lets a
521
+ // portable report record exactly which detector + threshold set produced each
522
+ // finding, so evidence stays reproducible as detectors evolve.
523
+ export function detectorCatalog() {
524
+ return DETECTORS.map((d) => ({
525
+ type: d.type,
526
+ version: d.version ?? 1,
527
+ scope: d.scope,
528
+ thresholds: d.thresholds,
529
+ docAnchor: d.docAnchor,
530
+ }));
531
+ }
532
+
533
+ // True task-duration skew ratio for a stage: P95/median once there are enough
534
+ // tasks to trust a P95 estimate, otherwise max/median. Returns null when the
535
+ // stage has no measurable median (p50 === 0). Exported so the CLI budget gate
536
+ // (src/cli/budgets.ts) can recompute the same ratio the detector uses, rather
537
+ // than reading the detector's own findings (which are floored at ratioWarn).
538
+ //
539
+ // Fields widened to optional: the `skew` detector below only ever calls this
540
+ // with an already-finalized `DetectorStage` (all four always numeric by the
541
+ // time `analyze()` runs), but `cli/budgets.ts`'s `checkSkew` calls it
542
+ // directly against `AppModel.stages`: real `Stage` records for a stage that
543
+ // never received a `StageCompleted` event (e.g. an unfinished run) genuinely
544
+ // lack these fields (see `types.ts`'s `Stage`). The `as number` casts below
545
+ // preserve the original behavior byte-for-byte: dividing through an absent
546
+ // field still naturally produces `NaN` (as it always has for untyped JS
547
+ // callers), rather than adding a new guard that would change the result.
548
+ export function computeSkewRatio(
549
+ stage ,
550
+ minTasksForP95 ,
551
+ ) {
552
+ const { taskCount, taskDurationP50: p50, taskDurationP95: p95, taskDurationMax: max } = stage;
553
+ if (p50 === 0) return null;
554
+ return (taskCount ?? 0) >= minTasksForP95
555
+ ? { ratio: (p95 ) / (p50 ), metric: 'P95/median' }
556
+ : { ratio: (max ) / (p50 ), metric: 'max/median' };
557
+ }
558
+
559
+ // Absolute-magnitude floor expressed as a % of total app runtime rather than
560
+ // a fixed ms constant (mirrors computeSpillMagnitude's/slowHost's ratio+floor
561
+ // pattern above/below): a skew/straggler/taskStageSkew ratio computed on a
562
+ // handful of milliseconds is noise in a run that took hours, but the same
563
+ // absolute waste is real in a run that only took seconds; a fixed-ms floor
564
+ // can't scale between those. Used by the `skew` and `straggler` entries
565
+ // below, each gated via `clippedWasteMs` (below) against the *same
566
+ // occupancy-clipped* wall-clock figure
567
+ // src/impact-estimator.ts displays as that finding's savings, not the raw
568
+ // pre-clip delta, which can stay large after clipping collapses the
569
+ // recoverable time to near zero (the stage's own longest task already
570
+ // accounts for nearly all of its wall-clock window).
571
+ // NOT SOURCED: floor percentages are our own noise floor, unvalidated.
572
+ function computeAppDurationMs(ctx ) {
573
+ const app = ctx?.app;
574
+ if (app?.startTime == null || app?.endTime == null) return null;
575
+ const durationMs = app.endTime - app.startTime;
576
+ return durationMs > 0 ? durationMs : null;
577
+ }
578
+
579
+ // Unknown app timing (computeAppDurationMs returned null) never suppresses a
580
+ // finding; it just skips the floor gate, preserving prior ratio-only
581
+ // behavior when total runtime can't be computed.
582
+ function meetsRuntimeFloor(wasteMs , appDurationMs , floorPct ) {
583
+ return appDurationMs == null || wasteMs >= appDurationMs * floorPct;
584
+ }
585
+
586
+ // Runs a raw waste delta through the same ceiling/occupancy clip
587
+ // src/impact-estimator.ts's singleStageImpact applies before display, so the
588
+ // runtime floor above is checked against a stage's actual recoverable
589
+ // wall-clock time rather than a delta that a physical floor (the stage's own
590
+ // longest task) may leave almost entirely unrecoverable. Falls back to the
591
+ // raw delta when occupancy data isn't available for this stage (ctx omitted,
592
+ // or the stage was excluded from the occupancy sweep for having <= 0
593
+ // duration), same as the pre-existing ratio-only behavior for unknown app
594
+ // timing above.
595
+ function clippedWasteMs(wasteMs , stageId , ctx ) {
596
+ if (!ctx) return wasteMs;
597
+ const est = estimateSingleStage(wasteMs, stageId, ctx.stages , ctx.occupancy);
598
+ return est ? est.wallClock.high : wasteMs;
599
+ }
600
+
601
+ // cacheUtilization's per-RDD copy (private to this file), following the
602
+ // existing pattern of small per-detector formatting helpers (e.g.
603
+ // pickDominantReason above). Both variants share the same confidence/
604
+ // validationRequired text: the ratio is a point-in-time storage snapshot
605
+ // from stage-submission events (src/event-handlers.js's mergeStageRddInfo),
606
+ // not a runtime block-access read-count.
607
+ const CACHE_UTILIZATION_VALIDATION =
608
+ "This ratio is a point-in-time storage snapshot from stage-submission events, not a runtime read-count. Confirm against the Spark UI's Storage tab before acting.";
609
+
610
+ function partialCacheFinding(rdd , cachedRatio , impactBand ) {
611
+ const rddName = rdd.name || `RDD ${rdd.id}`;
612
+ const cachedPct = Math.round(cachedRatio * 100);
613
+ const evictedPct = 100 - cachedPct;
614
+ return {
615
+ type: 'cacheUtilization', variant: 'partialCache', stageId: null,
616
+ rddId: rdd.id, rddName,
617
+ impactBand, metric: 'cachedRatio', value: cachedPct,
618
+ confidence: 'medium',
619
+ validationRequired: CACHE_UTILIZATION_VALIDATION,
620
+ memorySize: rdd.memorySize, diskSize: rdd.diskSize,
621
+ numCachedPartitions: rdd.numCachedPartitions, numPartitions: rdd.numPartitions,
622
+ recommendation: `RDD ${rddName} is ${evictedPct}% evicted from cache (${cachedPct}% of partitions cached). Increase executor memory or reduce the cached dataset size.`,
623
+ };
624
+ }
625
+
626
+ function diskSpilloverFinding(rdd , diskRatio , impactBand ) {
627
+ const rddName = rdd.name || `RDD ${rdd.id}`;
628
+ const diskPct = Math.round(diskRatio * 100);
629
+ return {
630
+ type: 'cacheUtilization', variant: 'diskSpillover', stageId: null,
631
+ rddId: rdd.id, rddName,
632
+ impactBand, metric: 'diskRatio', value: diskPct,
633
+ confidence: 'medium',
634
+ validationRequired: CACHE_UTILIZATION_VALIDATION,
635
+ memorySize: rdd.memorySize, diskSize: rdd.diskSize,
636
+ numCachedPartitions: rdd.numCachedPartitions, numPartitions: rdd.numPartitions,
637
+ recommendation: `RDD ${rddName} is ${diskPct}% spilled to disk despite requesting MEMORY_AND_DISK. Executor memory may be too small for this cached dataset.`,
638
+ };
639
+ }
640
+
641
+ // Entry shape for every item in DETECTORS. `TTarget` stays `unknown` at the
642
+ // array level (kept as the default, never instantiated per-scope): `detect`'s
643
+ // real first-argument shape varies by `scope` (a `DetectorStage` for
644
+ // 'stage', a `DetectorSqlExec` for 'sql', a `DetectorCtx` for 'app', or a
645
+ // narrower `{ app }` for 'config'; see analyzer.js's `analyze`/`auditConfig`,
646
+ // the only real callers), and unifying those four into one `TTarget` would
647
+ // need either an unsound cast or a discriminated-union-of-detectors redesign
648
+ // this migration task doesn't ask for. Each entry below still gets a
649
+ // precisely-typed `detect` by annotating its own `target`/`ctx` parameters
650
+ // directly: object-literal methods (this `detect(target) {}` shorthand, not
651
+ // an arrow function assigned to a property) are checked bivariantly against
652
+ // an interface's method parameter types, so a narrower, concrete annotation
653
+ // here does not conflict with `Detector`'s `unknown` declaration.
654
+
655
+
656
+
657
+
658
+
659
+
660
+
661
+
662
+
663
+
664
+
665
+
666
+
667
+
668
+
669
+
670
+
671
+
672
+
673
+
674
+
675
+ // Threshold field naming convention:
676
+ // *Pct = 0–1 fraction (normalized)
677
+ // *Pct100 = 0–100 scale
678
+ // *Ratio = multiplicative factor
679
+ // *Share/*Rate/*Util = 0–1 fraction (normalized)
680
+
681
+ // `straggler`'s own noise-floor thresholds (NOT SOURCED: unvalidated),
682
+ // exported so src/impact-band.ts can reuse the same figures as its global
683
+ // impact-band floor instead of hand-copying the literals.
684
+ export const STRAGGLER_FLOOR_PCT_WARN = 0.005;
685
+ export const STRAGGLER_FLOOR_PCT_CRIT = 0.02;
686
+
687
+ export const DETECTORS = [
688
+ {
689
+ type: 'skew', scope: 'stage', order: 30, fixEffort: 'code', version: 1,
690
+ docAnchor: '#bottleneck-skew',
691
+ thresholds: { ratioWarn: 3, minTasksForP95: 20, floorPctWarn: 0.005 },
692
+ detect(
693
+
694
+
695
+
696
+ stage ,
697
+ ctx ,
698
+ ) {
699
+ const result = computeSkewRatio(stage, this.thresholds.minTasksForP95);
700
+ if (result === null) return null;
701
+ const { ratio, metric } = result;
702
+ if (ratio <= this.thresholds.ratioWarn) return null;
703
+ // Same absolute delta src/impact-estimator.ts's 'skew' case reports as
704
+ // this finding's savings; clipped the same way before the floor check
705
+ // so the gate agrees with what's actually displayed.
706
+ const wasteMs = Math.max(0, metric === 'P95/median' ? stage.taskDurationP95 - stage.taskDurationP50 : stage.taskDurationMax - stage.taskDurationP50);
707
+ const appDurationMs = computeAppDurationMs(ctx);
708
+ const floorWasteMs = clippedWasteMs(wasteMs, stage.id, ctx);
709
+ if (!meetsRuntimeFloor(floorWasteMs, appDurationMs, this.thresholds.floorPctWarn)) return null;
710
+ const value = Math.round(ratio * 10) / 10;
711
+ return {
712
+ type: 'skew', stageId: stage.id,
713
+ impactBand: 'warning',
714
+ metric, value,
715
+ recommendation: `Task duration ratio (${metric}) is ${value}×: for join-driven skew, enable AQE skew-join handling (spark.sql.adaptive.skewJoin.enabled); otherwise salt the key or repartition on a better key to reduce task skew.`,
716
+ };
717
+ },
718
+ },
719
+ {
720
+ type: 'stageShape', scope: 'stage', order: 35, fixEffort: 'code', version: 1,
721
+ docAnchor: '#bottleneck-stage-shape',
722
+ thresholds: { pRatioMax: 0.5, oiRatioMax: 10, skewWarn: 3 },
723
+ detect(
724
+
725
+ stage ,
726
+ ctx ,
727
+ ) {
728
+ const out = [];
729
+ const execCount = (stage.executorStats ?? []).length;
730
+ const cores = ctx?.app?.resources?.executor?.cores ?? 1;
731
+ const totalCores = execCount * cores;
732
+ // PRatio: under-parallelization.
733
+ if (totalCores > 0) {
734
+ const pRatio = stage.taskCount / totalCores;
735
+ if (pRatio < this.thresholds.pRatioMax) {
736
+ out.push({
737
+ type: 'stageShape', stageId: stage.id, impactBand: 'info',
738
+ rule: 'lowParallelism', metric: 'pRatio', value: Math.round(pRatio * 100) / 100,
739
+ // Absolute core count behind pRatio, for the impact estimator's idle-core-ms figure.
740
+ totalCores,
741
+ recommendation: `This stage runs ${stage.taskCount} ${stage.taskCount === 1 ? 'task' : 'tasks'} across ~${totalCores} cores, so it is under-parallelized and leaves cluster capacity idle.`,
742
+ });
743
+ }
744
+ }
745
+ // OIRatio: data explosion. Skip when inputBytes is 0 (Infinity guard).
746
+ if (stage.inputBytes > 0) {
747
+ const oiRatio = stage.outputBytes / stage.inputBytes;
748
+ if (oiRatio > this.thresholds.oiRatioMax) {
749
+ out.push({
750
+ type: 'stageShape', stageId: stage.id, impactBand: 'info',
751
+ rule: 'dataExplosion', metric: 'oiRatio', value: Math.round(oiRatio * 10) / 10,
752
+ recommendation: `This stage outputs ${Math.round(oiRatio)}× its input volume: check for an exploding join or a cross product.`,
753
+ });
754
+ }
755
+ }
756
+ // TaskStageSkew: straggler cost vs stage wall-clock. Skip near-zero duration.
757
+ // Always info, like its lowParallelism/dataExplosion siblings above: satisfying
758
+ // this trigger mathematically forces the occupancy-clipped wall-clock estimate to
759
+ // exactly zero on every firing (see impact-estimator.ts's costOnly branch below),
760
+ // so there is no wall-clock-backed impact-band tier left to gate on.
761
+ const stageDurationMs = (stage.completedAt ?? 0) - (stage.submittedAt ?? 0);
762
+ if (stageDurationMs > 0) {
763
+ const ratio = stage.taskDurationMax / stageDurationMs;
764
+ if (ratio > this.thresholds.skewWarn) {
765
+ out.push({
766
+ type: 'stageShape', stageId: stage.id, impactBand: 'info',
767
+ rule: 'taskStageSkew', metric: 'taskStageSkew', value: Math.round(ratio * 10) / 10,
768
+ // Absolute core count, for the impact estimator's idle-core-ms figure.
769
+ totalCores,
770
+ recommendation: `One task takes ${Math.round(ratio * 10) / 10}× this stage's wall-clock duration; a single straggler is gating the whole stage.`,
771
+ });
772
+ }
773
+ }
774
+ return out;
775
+ },
776
+ },
777
+ {
778
+ type: 'shuffle', scope: 'stage', order: 20, fixEffort: 'config', version: 1,
779
+ docAnchor: '#bottleneck-shuffle',
780
+ thresholds: { minBytes: 50 * MB },
781
+ detect(
782
+
783
+ stage ,
784
+ ) {
785
+ const bytes = stage.shuffleReadBytes;
786
+ if (bytes <= this.thresholds.minBytes) return null;
787
+ return {
788
+ type: 'shuffle', stageId: stage.id,
789
+ impactBand: 'info',
790
+ metric: 'shuffleReadBytes', value: bytes,
791
+ recommendation: `${formatBytes(bytes)} shuffled in this stage: consider increasing spark.sql.shuffle.partitions or adding a broadcast join.`,
792
+ };
793
+ },
794
+ },
795
+ {
796
+ type: 'partitionSizing', scope: 'stage', order: 22, fixEffort: 'config', version: 1,
797
+ docAnchor: '#bottleneck-shuffle',
798
+ thresholds: { skewRatio: 5, skewFloorBytes: 256 * MB, lowParTotalBytes: GB, lowParMaxTasks: 7, maxPartBytes: 5 * GB },
799
+ detect(
800
+
801
+
802
+
803
+
804
+
805
+
806
+ stage ,
807
+ ) {
808
+ const out = [];
809
+ const { shuffleReadP50: p50, shuffleReadMax: max, shuffleReadBytes: total, taskCount } = stage;
810
+ if (max > this.thresholds.skewRatio * p50 && max > this.thresholds.skewFloorBytes) {
811
+ // p50 can be 0 (more than half the shuffle partitions empty): a
812
+ // ratio against zero renders the literal string "Infinity×", so fall
813
+ // back to median-free phrasing instead of dividing by p50.
814
+ const ratioText = p50 > 0
815
+ ? `${Math.round(max / p50 * 10) / 10}× the median (${formatBytes(p50)})`
816
+ : `far larger than the median (${formatBytes(p50)}, effectively empty)`;
817
+ out.push({
818
+ type: 'partitionSizing', stageId: stage.id, impactBand: 'warning',
819
+ rule: 'shufflePartitionSkew', metric: 'shuffleReadMax', value: max,
820
+ recommendation: `The largest shuffle partition (${formatBytes(max)}) is ${ratioText}: for join skew, enable AQE skew-join handling (spark.sql.adaptive.skewJoin.enabled); otherwise salt the key or repartition on a better key.`,
821
+ });
822
+ }
823
+ if (total >= this.thresholds.lowParTotalBytes && taskCount <= this.thresholds.lowParMaxTasks) {
824
+ out.push({
825
+ type: 'partitionSizing', stageId: stage.id, impactBand: 'warning',
826
+ rule: 'lowShuffleParallelism', metric: 'taskCount', value: taskCount,
827
+ recommendation: `${Math.round(total / GB * 10) / 10} GB of shuffle spread over only ${taskCount} tasks: raise spark.sql.shuffle.partitions so each partition is smaller.`,
828
+ });
829
+ }
830
+ if (max >= this.thresholds.maxPartBytes) {
831
+ out.push({
832
+ type: 'partitionSizing', stageId: stage.id, impactBand: 'critical',
833
+ rule: 'maxPartitionTooBig', metric: 'shuffleReadMax', value: max,
834
+ recommendation: `A single shuffle partition (${formatBytes(max)}) exceeds 5 GB: this will OOM or spill heavily. Repartition to break it up before this stage.`,
835
+ });
836
+ }
837
+ return out;
838
+ },
839
+ },
840
+ {
841
+ type: 'spill', scope: 'stage', order: 10, fixEffort: 'code', version: 1,
842
+ docAnchor: '#bottleneck-spill',
843
+ thresholds: { singleTaskDiskGiB: 1, singleTaskMemGiB: 4, highDiskGiB: 1, highTaskDiskMB: 512, highMemGiB: 4, medDiskMB: 256, medMemGiB: 1, skewRatio: 5, skewDiskFloorMB: 128, skewMemFloorMB: 256, skewMinTasks: 10 },
844
+ detect( stage ) {
845
+ if (stage.memoryBytesSpilled === 0) return null;
846
+ const cls = stage.spillClassification;
847
+ const classified = cls === 'skew' || cls === 'volume';
848
+ const mag = computeSpillMagnitude(stage, this.thresholds);
849
+ const impactBand = 'warning';
850
+ return {
851
+ type: 'spill', stageId: stage.id, impactBand,
852
+ spillMagnitude: mag?.magnitude,
853
+ metric: 'memoryBytesSpilled', value: stage.memoryBytesSpilled,
854
+ confidence: classified ? 'medium' : 'low',
855
+ validationRequired: classified
856
+ ? 'Spill cause is inferred from the share of tasks that spilled: confirm against per-task spill metrics in the Spark UI.'
857
+ : 'Spill cause could not be classified: inspect per-task spill metrics in the Spark UI before acting.',
858
+ recommendation: cls === 'skew'
859
+ ? `${formatBytes(stage.memoryBytesSpilled)} spilled, skew-driven: fix task skew first; adding memory will not help.`
860
+ : `${formatBytes(stage.memoryBytesSpilled)} spilled: raise spark.sql.shuffle.partitions or increase executor memory.`,
861
+ };
862
+ },
863
+ },
864
+ {
865
+ type: 'gc', scope: 'stage', order: 50, fixEffort: 'config', version: 1,
866
+ docAnchor: '#bottleneck-gc',
867
+ thresholds: {
868
+ warnPct100: 10,
869
+ // Descending tier: Dr. Elephant ExecutorGcHeuristic, ported as-is.
870
+ lowInfoPct100: 5,
871
+ // NOT SOURCED: our own noise floor so a stage that barely ran (gcPct
872
+ // near 0 or wildly inflated by a tiny denominator) does not flag,
873
+ // in either direction.
874
+ minRunTimeMs: 10000,
875
+ },
876
+ detect(
877
+
878
+
879
+
880
+ stage ,
881
+ ) {
882
+ const pct = stage.gcPct;
883
+ if ((stage.executorRunTime ?? 0) >= this.thresholds.minRunTimeMs
884
+ && pct > this.thresholds.warnPct100) {
885
+ const value = Math.round(pct * 10) / 10;
886
+ return {
887
+ type: 'gc', stageId: stage.id,
888
+ impactBand: 'warning',
889
+ metric: 'gcPct', value,
890
+ recommendation: `GC consumed ${value}% of executor run time: reduce object creation, use primitive types, avoid UDFs, increase executor memory.`,
891
+ };
892
+ }
893
+ // Low-GC (cost) branch: only for stages that ran long enough to be meaningful.
894
+ if ((stage.executorRunTime ?? 0) >= this.thresholds.minRunTimeMs
895
+ && pct < this.thresholds.lowInfoPct100) {
896
+ const value = Math.round(pct * 10) / 10;
897
+ return {
898
+ type: 'gc', stageId: stage.id, direction: 'low',
899
+ impactBand: 'info',
900
+ metric: 'gcPct', value,
901
+ recommendation: `GC consumed only ${value}% of executor run time: memory may be over-provisioned; consider reducing spark.executor.memory for cost savings.`,
902
+ };
903
+ }
904
+ return null;
905
+ },
906
+ },
907
+ {
908
+ type: 'slowHost', scope: 'stage', order: 60, fixEffort: 'config', version: 1,
909
+ docAnchor: '#bottleneck-slow-host',
910
+ thresholds: {
911
+ minHosts: 3, minTasks: 15, ratioWarn: 2.0, minShare: 0.20, shareWarn: 0.75, taskShareWarn: 0.50, ratioTiers: [1.33, 1.78, 3.16, 10],
912
+ // Absolute-magnitude floors (mirrors computeSpillMagnitude's ratio+floor
913
+ // pattern): on short stages, sub-second/sub-64MB differences between
914
+ // hosts or executors produce huge ratios that are pure noise, not a
915
+ // real slow-host problem. 1s is well above typical per-task scheduling
916
+ // jitter but well below the tens-of-seconds+ means genuine slow-host
917
+ // stages exhibit; 64MB mirrors the spill detector's disk-skew floor.
918
+ floorMs: 1000, floorBytes: 64 * MB,
919
+ },
920
+ detect(
921
+
922
+
923
+
924
+
925
+
926
+
927
+ stage ,
928
+ ) {
929
+ const hosts = stage.hostStats ?? [];
930
+ const execs0 = stage.executorStats ?? [];
931
+ if ((hosts.length < this.thresholds.minHosts && execs0.length < this.thresholds.minHosts) || stage.taskCount < this.thresholds.minTasks) return null;
932
+ const out = [];
933
+ if (hosts.length >= this.thresholds.minHosts) {
934
+ const means = hosts.map(h => ({ host: h.host, taskCount: h.taskCount, mean: h.totalDuration / h.taskCount }));
935
+ const sorted = [...means].map(h => h.mean).sort((a, b) => a - b);
936
+ const overallMedian = sorted[Math.floor(sorted.length / 2)];
937
+ if (overallMedian > 0) {
938
+ for (const h of means) {
939
+ const ratio = h.mean / overallMedian;
940
+ const share = h.taskCount / stage.taskCount;
941
+ if (ratio < this.thresholds.ratioWarn || share < this.thresholds.minShare || h.mean < this.thresholds.floorMs) continue;
942
+ out.push({
943
+ type: 'slowHost', stageId: stage.id,
944
+ impactBand: 'warning',
945
+ metric: 'hostMeanRatio', value: Math.round(ratio * 10) / 10,
946
+ // `value` is a ratio; the estimator needs the absolute per-host mean.
947
+ hostMeanMs: h.mean,
948
+ host: h.host, hostTaskShare: Math.round(share * 100) / 100,
949
+ recommendation: `Check executor logs for ${h.host}: possible bad node, disk pressure, or NUMA misalignment.`,
950
+ });
951
+ }
952
+ }
953
+ const totalDuration = hosts.reduce((s, h) => s + h.totalDuration, 0);
954
+ if (totalDuration > 0) {
955
+ for (const h of hosts) {
956
+ const durationShare = h.totalDuration / totalDuration;
957
+ const taskShare = h.taskCount / stage.taskCount;
958
+ if (durationShare >= this.thresholds.shareWarn && taskShare >= this.thresholds.taskShareWarn) {
959
+ out.push({
960
+ type: 'slowHost', stageId: stage.id, impactBand: 'warning',
961
+ variant: 'durationShare',
962
+ metric: 'hostDurationShare', value: Math.round(durationShare * 100) / 100,
963
+ // `value` is a 0-1 share; the estimator needs the absolute per-host mean.
964
+ hostMeanMs: h.totalDuration / h.taskCount,
965
+ host: h.host, hostTaskShare: Math.round(taskShare * 100) / 100,
966
+ recommendation: `${h.host} is doing ${Math.round(durationShare * 100)}% of this stage's total task time: check for data locality or partition assignment skewing work onto one node.`,
967
+ });
968
+ }
969
+ }
970
+ }
971
+ }
972
+ const execs = stage.executorStats ?? [];
973
+ const tiers = this.thresholds.ratioTiers;
974
+ const impactBandFor = (r ) =>
975
+ r >= tiers[3] ? 'critical' : (r >= tiers[1] ? 'warning' : (r >= tiers[0] ? 'info' : null));
976
+ const floorMs = this.thresholds.floorMs, floorBytes = this.thresholds.floorBytes;
977
+ const dims = [
978
+ { dimension: 'taskTime', floor: floorMs, samples: execs.filter(e => e.taskCount > 0).map(e => ({ key: e.executorId, value: e.totalDuration / e.taskCount })) },
979
+ { dimension: 'inputBytes', floor: floorBytes, samples: execs.map(e => ({ key: e.executorId, value: e.inputBytes ?? 0 })) },
980
+ { dimension: 'shuffleBytes', floor: floorBytes, samples: execs.map(e => ({ key: e.executorId, value: (e.shuffleReadBytes ?? 0) + (e.shuffleWriteBytes ?? 0) })) },
981
+ ];
982
+ // Storage-memory dimension: best-effort, only when executorMetrics present.
983
+ const em = stage.executorMetrics instanceof Map ? stage.executorMetrics : null;
984
+ if (em && em.size >= 3) {
985
+ dims.push({ dimension: 'storageMemory', floor: floorBytes, samples: [...em.entries()].map(([id, m]) => ({ key: id, value: (m.onHeapStorageMemory ?? 0) + (m.offHeapStorageMemory ?? 0) })) });
986
+ }
987
+ for (const d of dims) {
988
+ const r = maxMedianRatio(d.samples);
989
+ if (!r || r.value < d.floor) continue; // absolute-magnitude floor: same ratio+floor shape as computeSpillMagnitude
990
+ const tier = impactBandFor(r.ratio);
991
+ if (!tier) continue;
992
+ // taskTime is the only wallClock-bearing dimension here: fixed fallback
993
+ // (its current floor case), overwritten by deriveImpactBand whenever this
994
+ // finding gets a real wallClock estimate. The other three dimensions never
995
+ // get a wallClock estimate, so they keep the dynamic tier unchanged.
996
+ const impactBand = d.dimension === 'taskTime' ? 'info' : tier;
997
+ out.push({
998
+ type: 'slowHost', stageId: stage.id, impactBand,
999
+ variant: 'multiDim', dimension: d.dimension,
1000
+ metric: 'execMaxMedianRatio', value: Math.round(r.ratio * 10) / 10,
1001
+ // `value` is a ratio; `execMaxValue` is the deviating sample's raw magnitude
1002
+ // in this dimension's own unit (ms for taskTime, bytes for the rest).
1003
+ execMaxValue: r.value,
1004
+ executorId: r.key,
1005
+ recommendation: `Executor ${r.key} deviates ${Math.round(r.ratio * 10) / 10}× from the median on ${d.dimension}: investigate uneven partition assignment or a degraded executor.`,
1006
+ });
1007
+ }
1008
+ return out;
1009
+ },
1010
+ },
1011
+ {
1012
+ type: 'stageSlowness', scope: 'stage', order: 65, fixEffort: 'code', version: 2,
1013
+ docAnchor: '#bottleneck-stage-slowness',
1014
+ thresholds: { infoMin: 15 },
1015
+ // Cross-detector suppression (new pattern; see "Detector contract" in
1016
+ // docs-site/contributor-guide/architecture/detector-contract.md).
1017
+ // Requires this entry to be declared AFTER slowHost in DETECTORS so
1018
+ // slowHost findings are already in `out`.
1019
+ suppressWhen(finding, out) {
1020
+ return out.some(o => o.type === 'slowHost' && o.stageId === finding.stageId);
1021
+ },
1022
+ detect(
1023
+
1024
+ stage ,
1025
+ ) {
1026
+ // Basis is real wall-clock stage duration, not per-executor average
1027
+ // (Decision 7); Task 11's impact-estimator formula reuses this exact
1028
+ // stageDurationMs computation.
1029
+ const stageDurationMs = (stage.completedAt ?? 0) - (stage.submittedAt ?? 0);
1030
+ if (!(stageDurationMs > 0)) return null;
1031
+ const durationMinutes = stageDurationMs / 60000;
1032
+ const t = this.thresholds;
1033
+ const impactBand = durationMinutes >= t.infoMin ? 'info' : null;
1034
+ if (!impactBand) return null;
1035
+ const value = Math.round(durationMinutes * 10) / 10;
1036
+ return {
1037
+ type: 'stageSlowness', stageId: stage.id, impactBand,
1038
+ metric: 'stageDurationMinutes', value,
1039
+ recommendation: `This stage ran ${value} minutes with no more specific cause flagged: profile its query plan and check for wide shuffles or expensive UDFs.`,
1040
+ };
1041
+ },
1042
+ },
1043
+ {
1044
+ type: 'stageFailed', scope: 'stage', order: 42, fixEffort: 'code', version: 1,
1045
+ docAnchor: '#bottleneck-failures',
1046
+ thresholds: {},
1047
+ detect(stage ) {
1048
+ if (stage.stageFailureReason == null) return null;
1049
+ return {
1050
+ type: 'stageFailed', stageId: stage.id, impactBand: 'critical',
1051
+ variant: 'stageFailure',
1052
+ metric: 'stageFailureReason', value: stage.stageFailureReason,
1053
+ recommendation: `This stage attempt failed outright. Inspect the driver log for the failure reason and the job that triggered it.`,
1054
+ };
1055
+ },
1056
+ },
1057
+ {
1058
+ type: 'failures', scope: 'stage', order: 40, fixEffort: 'code', version: 1,
1059
+ docAnchor: '#bottleneck-failures',
1060
+ thresholds: { minTasks: 10, warnRate: 0.05, critRate: 0.20 },
1061
+ detect(
1062
+
1063
+ stage ,
1064
+ ) {
1065
+ if (stage.taskCount < this.thresholds.minTasks) return null;
1066
+ if (!stage.failedTasks) return null;
1067
+ const failureRate = stage.failedTasks / stage.taskCount;
1068
+ if (failureRate <= this.thresholds.warnRate) return null;
1069
+ const value = Math.round(failureRate * 1000) / 10;
1070
+ const dominantReason = pickDominantReason(stage.failureReasons);
1071
+ return {
1072
+ type: 'failures', stageId: stage.id,
1073
+ impactBand: failureRate > this.thresholds.critRate ? 'critical' : 'warning',
1074
+ metric: 'failureRate', value,
1075
+ failedTasks: stage.failedTasks,
1076
+ dominantReason,
1077
+ recommendation: `${value}% of tasks failed${dominantReason ? ` (dominant reason: ${dominantReason})` : ''}: investigate driver logs for executor instability or data-driven errors.`,
1078
+ };
1079
+ },
1080
+ },
1081
+ {
1082
+ type: 'straggler', scope: 'stage', order: 70, fixEffort: 'code', version: 1,
1083
+ docAnchor: '#bottleneck-straggler',
1084
+ // floorPctWarn/floorPctCrit are re-exported as STRAGGLER_FLOOR_PCT_WARN/CRIT
1085
+ // below and reused as src/impact-band.ts's global noise floor: keep the two
1086
+ // in sync, don't hand-edit one without the other.
1087
+ thresholds: { minTasks: 10, shareWarn: 0.05, warnPct: 0.10, critPct: 0.20, floorPctWarn: STRAGGLER_FLOOR_PCT_WARN, floorPctCrit: STRAGGLER_FLOOR_PCT_CRIT },
1088
+ detect(
1089
+
1090
+
1091
+
1092
+ stage ,
1093
+ ctx ,
1094
+ ) {
1095
+ if (stage.taskCount < this.thresholds.minTasks) return null;
1096
+ const stragglerShare = (stage.stragglerCount ?? 0) / stage.taskCount;
1097
+ if ((stage.speculativeTasks ?? 0) === 0 && stragglerShare <= this.thresholds.shareWarn) return null;
1098
+ const useSpeculative = (stage.speculativeTasks ?? 0) > 0;
1099
+ const speculativeShare = useSpeculative ? stage.speculativeTasks / stage.taskCount : 0;
1100
+ // Same absolute delta src/impact-estimator.ts's shared straggler/
1101
+ // stageShape case reports as this finding's savings: a high share of
1102
+ // stragglers/speculative retries on a stage whose tasks barely vary in
1103
+ // duration models near-zero savings, so it must not outrank 'info'.
1104
+ // Clipped the same way before the floor check so the gate agrees with
1105
+ // what's actually displayed.
1106
+ const wasteMs = Math.max(0, stage.taskDurationMax - stage.taskDurationP50);
1107
+ const appDurationMs = computeAppDurationMs(ctx);
1108
+ const floorWasteMs = clippedWasteMs(wasteMs, stage.id, ctx);
1109
+ const meetsWarnFloor = meetsRuntimeFloor(floorWasteMs, appDurationMs, this.thresholds.floorPctWarn);
1110
+ const meetsCritFloor = meetsRuntimeFloor(floorWasteMs, appDurationMs, this.thresholds.floorPctCrit);
1111
+ const speculativeTier = speculativeShare >= this.thresholds.critPct && meetsCritFloor ? 'critical'
1112
+ : speculativeShare >= this.thresholds.warnPct && meetsWarnFloor ? 'warning' : 'info';
1113
+ // Straggler share has no dedicated critical tier per
1114
+ // docs-site/contributor-guide/architecture/detector-contract.md; it can only push to warning.
1115
+ const stragglerTier = stragglerShare > this.thresholds.shareWarn && meetsWarnFloor ? 'warning' : 'info';
1116
+ // Fixed fallback: overwritten by deriveImpactBand (src/impact-band.ts) whenever
1117
+ // this finding gets a real wallClock estimate, which is the common case. Only
1118
+ // surfaces on the rare miss (stage excluded from the occupancy sweep).
1119
+ const impactBand = 'info';
1120
+ // Report whichever signal actually drove the finding, not just whether
1121
+ // speculative execution happened to be on: a high stragglerShare with
1122
+ // few/no speculative retries must not be reported as a low-value
1123
+ // speculativeTasks count (real-log bug: a 50%-straggler-share stage
1124
+ // with 1 speculative task reported an impact band of 'warning' but metric
1125
+ // 'speculativeTasks: 1', hiding the actual cause). Ties keep the prior
1126
+ // default (speculative-driven) so existing speculative-only findings
1127
+ // are unaffected.
1128
+ const useSpeculativeMetric = useSpeculative && !(IMPACT_BAND_ORDER[stragglerTier] < IMPACT_BAND_ORDER[speculativeTier]);
1129
+ const value = useSpeculativeMetric ? stage.speculativeTasks : Math.round(stragglerShare * 100);
1130
+ const detail = useSpeculativeMetric
1131
+ ? `${value} speculative attempt${value === 1 ? '' : 's'} discarded`
1132
+ : `${value}% of tasks straggled`;
1133
+ return {
1134
+ type: 'straggler', stageId: stage.id, impactBand,
1135
+ metric: useSpeculativeMetric ? 'speculativeTasks' : 'stragglerShare',
1136
+ value,
1137
+ unit: useSpeculativeMetric ? 'count' : 'pct',
1138
+ speculativeTasks: stage.speculativeTasks ?? 0,
1139
+ stragglerCount: stage.stragglerCount ?? 0,
1140
+ recommendation: `${detail}: investigate stragglers, likely candidates for AQE skewJoin or data locality issues.`,
1141
+ };
1142
+ },
1143
+ },
1144
+ {
1145
+ type: 'speculationWaste', scope: 'stage', order: 71, fixEffort: 'config', version: 1,
1146
+ docAnchor: '#bottleneck-straggler', confidence: 'low',
1147
+ thresholds: { minWasted: 5, minWasteMs: 60000 },
1148
+ detect(
1149
+
1150
+
1151
+
1152
+
1153
+ stage ,
1154
+ ) {
1155
+ const wasted = stage.speculationWastedAttempts ?? 0;
1156
+ const wastedMs = stage.speculationWasteMs ?? 0;
1157
+ if (wasted < this.thresholds.minWasted || wastedMs < this.thresholds.minWasteMs) return null;
1158
+ return {
1159
+ type: 'speculationWaste', stageId: stage.id,
1160
+ impactBand: 'warning',
1161
+ metric: 'speculationWasteMs', value: wastedMs,
1162
+ confidence: this.confidence,
1163
+ recommendation: `Speculative execution discarded ${Math.round(wastedMs / 1000)}s of executor time in this stage; if task durations are naturally variable rather than genuine stragglers, consider tuning spark.speculation.multiplier/quantile.`,
1164
+ };
1165
+ },
1166
+ },
1167
+ {
1168
+ type: 'retryWaste', scope: 'stage', order: 45, fixEffort: 'code', version: 1,
1169
+ docAnchor: '#bottleneck-retry-waste',
1170
+ thresholds: { minWasted: 3, minWasteMs: 30000 },
1171
+ detect(
1172
+
1173
+ stage ,
1174
+ ) {
1175
+ const wasted = stage.wastedAttempts ?? 0;
1176
+ const wastedMs = stage.retryWasteMs ?? 0;
1177
+ if (wasted < this.thresholds.minWasted || wastedMs < this.thresholds.minWasteMs) return null;
1178
+ return {
1179
+ type: 'retryWaste', stageId: stage.id,
1180
+ impactBand: 'warning',
1181
+ metric: 'retryWasteMs', value: wastedMs,
1182
+ recommendation: `Retried task attempts wasted ${Math.round(wastedMs / 1000)}s of executor time (${wasted} attempt${wasted === 1 ? '' : 's'}) even though the stage completed: investigate executor loss or fetch failures.`,
1183
+ extended: `${wasted} task attempts were superseded by a later retry, wasting ${Math.round(wastedMs / 1000)}s of executor time. Common causes: executor loss (OOM-kill, node death) or shuffle FetchFailed forcing a stage-map recompute. Check driver logs for the dominant reason (see the Failures widget) even if the final failure rate looks low; retries hide the true cost.`,
1184
+ };
1185
+ },
1186
+ },
1187
+ {
1188
+ type: 'tinyTask', scope: 'stage', order: 80, fixEffort: 'code', version: 1,
1189
+ docAnchor: '#bottleneck-tiny-tasks',
1190
+ thresholds: { minTasks: 100, maxP50: 500, maxP95: 1000 },
1191
+ detect(
1192
+
1193
+ stage ,
1194
+ ) {
1195
+ if (stage.taskCount < this.thresholds.minTasks) return null;
1196
+ if (stage.taskDurationP50 > this.thresholds.maxP50 || stage.taskDurationP95 > this.thresholds.maxP95) return null;
1197
+ const coalesceTo = Math.max(1, Math.round(stage.taskCount / 10));
1198
+ const fix = stage.shuffleReadBytes > 0
1199
+ ? `lower spark.sql.shuffle.partitions or .coalesce(${coalesceTo})`
1200
+ : `.coalesce(${coalesceTo})`;
1201
+ return {
1202
+ type: 'tinyTask', stageId: stage.id, impactBand: 'info',
1203
+ metric: 'taskDurationP50', value: Math.round(stage.taskDurationP50),
1204
+ recommendation: `Many small tasks (${stage.taskCount}, P50 ${Math.round(stage.taskDurationP50)}ms): scheduler overhead may dominate. Try ${fix}.`,
1205
+ };
1206
+ },
1207
+ },
1208
+ {
1209
+ // No docAnchor set (documented deviation, like autoscalingChurn above):
1210
+ // the upstream `shuffle-works/spark-tuning-reference` docs repo has no
1211
+ // section for this tool-specific "capture stopped early" signal.
1212
+ type: 'incompleteRun', scope: 'app', order: 5, fixEffort: 'code', version: 1,
1213
+ thresholds: {},
1214
+ recommendation: 'This event log never recorded an ApplicationEnd event: the capture stopped before the run finished (an in-flight job, a rotated-away log, or a cut-short capture). Findings and metrics elsewhere on this board reflect only what was captured up to that point, not the full run.',
1215
+ detect( ctx ) {
1216
+ if (ctx.app.startTime == null || ctx.app.endTime != null) return null;
1217
+ return {
1218
+ type: 'incompleteRun', stageId: null, impactBand: 'warning',
1219
+ metric: 'applicationEnd', value: 'missing',
1220
+ recommendation: this.recommendation,
1221
+ };
1222
+ },
1223
+ },
1224
+ {
1225
+ type: 'coldStart', scope: 'app', order: 90, fixEffort: 'code', version: 1,
1226
+ docAnchor: '#bottleneck-cold-start',
1227
+ thresholds: { gapSeconds: 30 },
1228
+ detect(
1229
+
1230
+ ctx ,
1231
+ ) {
1232
+ const { app, stages } = ctx;
1233
+ if (!app.startTime || stages.size === 0) return null;
1234
+ let firstTaskLaunch = Infinity;
1235
+ for (const stage of stages.values()) {
1236
+ if (stage.submittedAt > 0 && stage.submittedAt < firstTaskLaunch) firstTaskLaunch = stage.submittedAt;
1237
+ }
1238
+ const gapSeconds = (firstTaskLaunch - app.startTime) / 1000;
1239
+ if (gapSeconds <= this.thresholds.gapSeconds) return null;
1240
+ const value = Math.round(gapSeconds);
1241
+ return {
1242
+ type: 'coldStart', stageId: null, impactBand: 'warning',
1243
+ metric: 'startupGapSeconds', value,
1244
+ recommendation: `Executor startup took ${value}s: consider pre-warming the cluster or using dynamic allocation.`,
1245
+ };
1246
+ },
1247
+ },
1248
+ {
1249
+ type: 'utilization', scope: 'app', order: 100, fixEffort: 'config', version: 1,
1250
+ docAnchor: '#bottleneck-utilization',
1251
+ thresholds: { minUtil: 0.60 },
1252
+ detect(
1253
+
1254
+ ctx ,
1255
+ ) {
1256
+ const { app, executorsAdded, executorsRemoved } = ctx;
1257
+ if (executorsAdded.length === 0 || !app.startTime || !app.endTime) return null;
1258
+ const appDuration = app.endTime - app.startTime;
1259
+ if (appDuration <= 0) return null;
1260
+ const peakExecutors = executorsAdded.length;
1261
+ let totalActiveMs = 0;
1262
+ const removed = new Map ();
1263
+ for (const ev of executorsRemoved) removed.set(ev.executorId, ev.timestamp);
1264
+ for (const ev of executorsAdded) {
1265
+ const addedAt = Math.max(ev.timestamp, app.startTime);
1266
+ const removedAt = removed.has(ev.executorId) ? removed.get(ev.executorId) : app.endTime;
1267
+ totalActiveMs += Math.max(0, removedAt - addedAt);
1268
+ }
1269
+ const utilization = (totalActiveMs / appDuration) / peakExecutors;
1270
+ if (utilization >= this.thresholds.minUtil) return null;
1271
+
1272
+ // CPU-time-based utilization (sparkMeasure): metric only, no threshold.
1273
+ // Total cores: prefer executor-added Total Cores (real), else config cores.
1274
+ const totalCores = computeTotalCores(app, executorsAdded);
1275
+ let cpuUtilizationPct = null;
1276
+ if (totalCores > 0) {
1277
+ let cpuMs = 0;
1278
+ // executorCpuTime is reported by Spark in nanoseconds.
1279
+ for (const s of ctx.stages.values()) cpuMs += nsToMs(s.executorCpuTime ?? 0);
1280
+ cpuUtilizationPct = Math.round((cpuMs / (appDuration * totalCores)) * 100);
1281
+ }
1282
+
1283
+ const value = Math.round(utilization * 100);
1284
+ return {
1285
+ type: 'utilization', stageId: null, impactBand: 'info',
1286
+ metric: 'avgUtilization', value,
1287
+ utilizationFraction: utilization,
1288
+ appDurationMs: appDuration,
1289
+ totalCores,
1290
+ cpuUtilizationPct,
1291
+ recommendation: `Average executor utilization was only ${value}%: consider reducing cluster size or enabling dynamic allocation.`,
1292
+ };
1293
+ },
1294
+ },
1295
+ {
1296
+ type: 'memoryUtilization', scope: 'app', order: 102, fixEffort: 'config', version: 1,
1297
+ docAnchor: '#bottleneck-memory-utilization',
1298
+ thresholds: {
1299
+ idleCoreWarn: 0.50, // dataflint WastedCoresAlertsReducer
1300
+ bandTooSmall: 0.95, // dataflint MemoryAlertsReducer: used/allocated
1301
+ bandTooHigh: 0.70, // below this => over-provisioned (cost signal)
1302
+ wasteBufferMultiplier: 1.5, // Dr. Elephant SparkMetricsAggregator: UNVERIFIED
1303
+ },
1304
+ detect(
1305
+
1306
+
1307
+
1308
+
1309
+
1310
+ ctx ,
1311
+ ) {
1312
+ const { app, executorsAdded, runAggregates, stages } = ctx;
1313
+ const out = [];
1314
+ // Nullish (not falsy) check: unlike the older coldStart/utilization
1315
+ // detectors, a literal startTime:0 must not be treated as "missing".
1316
+ if (app?.startTime == null || app?.endTime == null) return out;
1317
+ const appDurationMs = app.endTime - app.startTime;
1318
+ if (appDurationMs <= 0) return out;
1319
+
1320
+ // Total cores: prefer real Executor-Added Total Cores, else config.
1321
+ const peakExecutors = executorsAdded.length;
1322
+ const totalCores = computeTotalCores(app, executorsAdded);
1323
+ // Hoisted above 1a (it is also 1b/1c's input) so the idle-cores finding can carry
1324
+ // the allocated memory its MB-seconds impact estimate needs.
1325
+ const allocatedMB = app.resources?.executor?.memoryMB ?? null;
1326
+
1327
+ // ── 1a idle-cores rate ────────────────────────────────────────────────
1328
+ if (runAggregates && totalCores > 0) {
1329
+ const capacityCoreMs = totalCores * appDurationMs;
1330
+ const idleRate = capacityCoreMs > 0 ? 1 - (runAggregates.busyCoreMs / capacityCoreMs) : 0;
1331
+ if (idleRate > this.thresholds.idleCoreWarn) {
1332
+ const value = Math.round(idleRate * 100);
1333
+ out.push({
1334
+ type: 'memoryUtilization', variant: 'idleCores', stageId: null,
1335
+ impactBand: 'warning', metric: 'idleCoreRate', value,
1336
+ // Raw (unrounded) rate plus the sizing inputs, for the impact estimator's
1337
+ // wasted-MB-seconds model: `value` above is a rounded percentage.
1338
+ idleRateFraction: idleRate, allocatedMB, peakExecutors, appDurationMs,
1339
+ recommendation: `${value}% of allocated core-time ran no task: reduce cluster size or enable dynamic allocation.`,
1340
+ });
1341
+ }
1342
+ }
1343
+
1344
+ // ── 1b memory bands (executor only; driver half dropped since there is no driver metric) ─
1345
+ // Peak heap per executor = max jvmHeapMemory across all stages' executorMetrics.
1346
+ const peakHeapByExec = new Map ();
1347
+ for (const s of stages.values()) {
1348
+ const em = s.executorMetrics;
1349
+ if (!(em instanceof Map)) continue;
1350
+ for (const [execId, m] of em) {
1351
+ const heap = m?.jvmHeapMemory ?? 0;
1352
+ if (heap > (peakHeapByExec.get(execId) ?? 0)) peakHeapByExec.set(execId, heap);
1353
+ }
1354
+ }
1355
+ if (peakHeapByExec.size === 0) {
1356
+ out.push({
1357
+ type: 'memoryUtilization', variant: 'memoryBand', stageId: null,
1358
+ impactBand: 'info', metric: 'memoryBand', dataUnavailable: true,
1359
+ recommendation: 'Per-executor memory usage requires spark.eventLog.logStageExecutorMetrics=true: not enabled for this run.',
1360
+ });
1361
+ } else if (allocatedMB != null && allocatedMB > 0) {
1362
+ const allocatedBytes = allocatedMB * 1024 * 1024;
1363
+ for (const [execId, heap] of peakHeapByExec) {
1364
+ const ratio = heap / allocatedBytes;
1365
+ // The two bands are opposite signals, not two degrees of one: an explicit
1366
+ // `rule` discriminator (same pattern as stageShape) lets consumers tell the
1367
+ // OOM-risk case from the over-provisioning waste case without re-deriving
1368
+ // the ratio against the thresholds.
1369
+ if (ratio > this.thresholds.bandTooSmall) {
1370
+ out.push({
1371
+ type: 'memoryUtilization', variant: 'memoryBand', rule: 'heapNearCapacity',
1372
+ stageId: null, executorId: execId,
1373
+ impactBand: 'warning', metric: 'heapUsedRatio', value: Math.round(ratio * 100),
1374
+ recommendation: `Executor ${execId} peaked at ${Math.round(ratio * 100)}% of allocated heap: memory may be too small; raise spark.executor.memory to avoid OOM/spill.`,
1375
+ });
1376
+ } else if (ratio < this.thresholds.bandTooHigh) {
1377
+ out.push({
1378
+ type: 'memoryUtilization', variant: 'memoryBand', rule: 'heapOverProvisioned',
1379
+ stageId: null, executorId: execId,
1380
+ impactBand: 'info', metric: 'heapUsedRatio', value: Math.round(ratio * 100),
1381
+ // Absolute figures behind the rounded ratio, for the estimator's
1382
+ // unused-memory-over-time model.
1383
+ allocatedBytes, heap, appDurationMs,
1384
+ recommendation: `Executor ${execId} used only ${Math.round(ratio * 100)}% of allocated heap: memory may be over-provisioned; consider reducing spark.executor.memory for cost savings.`,
1385
+ });
1386
+ }
1387
+ }
1388
+ }
1389
+
1390
+ // ── 1c Spark Memory Limit waste model (Dr. Elephant, UNVERIFIED buffer) ─
1391
+ if (allocatedMB != null && peakExecutors > 0) {
1392
+ const allocatedMBSeconds = peakExecutors * allocatedMB * (appDurationMs / 1000);
1393
+ let usedRunTimeMs = 0;
1394
+ for (const s of stages.values()) usedRunTimeMs += s.executorRunTime ?? 0;
1395
+ const usedMBSeconds = allocatedMB * (usedRunTimeMs / 1000);
1396
+ const wastedMBSeconds = allocatedMBSeconds - usedMBSeconds;
1397
+ if (wastedMBSeconds > this.thresholds.wasteBufferMultiplier * usedMBSeconds) {
1398
+ const value = Math.round(wastedMBSeconds);
1399
+ out.push({
1400
+ type: 'memoryUtilization', variant: 'wasteModel', stageId: null,
1401
+ impactBand: 'info', metric: 'wastedMBSeconds', value,
1402
+ confidence: 'low',
1403
+ validationRequired: 'Memory-waste estimate uses allocated-vs-used memory-time and an unverified 1.5x buffer ported from Dr. Elephant: confirm against the Spark UI before acting.',
1404
+ recommendation: `Allocated executor memory sat largely idle over the run (~${value.toLocaleString('en-US')} MB-seconds wasted): review spark.executor.memory and executor count.`,
1405
+ });
1406
+ }
1407
+ }
1408
+
1409
+ return out;
1410
+ },
1411
+ },
1412
+ {
1413
+ // Per-RDD cache-utilization proxies (this repo's own design: Spark
1414
+ // event logs carry no block-access/read-count events, so a literal
1415
+ // cache hit rate isn't derivable; see the design doc for GitHub issue
1416
+ // #85). Two independent, per-RDD tiered checks over `ctx.app.rddInfo`
1417
+ // storage snapshots: partial caching (numCachedPartitions < numPartitions)
1418
+ // and disk spillover (diskSize share of a MEMORY_AND_DISK*-requesting
1419
+ // RDD's cached footprint). An RDD can produce both findings in one pass.
1420
+ type: 'cacheUtilization', scope: 'app', order: 103, fixEffort: 'code', version: 1,
1421
+ docAnchor: '#memory-model',
1422
+ thresholds: {
1423
+ cachedRatioWarn: 0.50, cachedRatioInfo: 0.90,
1424
+ diskRatioWarn: 0.40, diskRatioInfo: 0.15,
1425
+ },
1426
+ detect(
1427
+
1428
+
1429
+
1430
+
1431
+
1432
+ ctx ,
1433
+ ) {
1434
+ const rddInfo = ctx.app?.rddInfo;
1435
+ if (!(rddInfo instanceof Map)) return null;
1436
+ const out = [];
1437
+ for (const rdd of rddInfo.values()) {
1438
+ const sl = rdd.storageLevel ?? {};
1439
+ if (!(sl.useMemory || sl.useDisk)) continue;
1440
+ if (!((rdd.numCachedPartitions ?? 0) > 0)) continue;
1441
+
1442
+ if ((rdd.numPartitions ?? 0) > 0) {
1443
+ const cachedRatio = rdd.numCachedPartitions / rdd.numPartitions;
1444
+ if (cachedRatio < this.thresholds.cachedRatioWarn) out.push(partialCacheFinding(rdd, cachedRatio, 'warning'));
1445
+ else if (cachedRatio < this.thresholds.cachedRatioInfo) out.push(partialCacheFinding(rdd, cachedRatio, 'info'));
1446
+ }
1447
+
1448
+ if (sl.useMemory && sl.useDisk) {
1449
+ const total = (rdd.memorySize ?? 0) + (rdd.diskSize ?? 0);
1450
+ if (total > 0) {
1451
+ const diskRatio = (rdd.diskSize ?? 0) / total;
1452
+ if (diskRatio > this.thresholds.diskRatioWarn) out.push(diskSpilloverFinding(rdd, diskRatio, 'warning'));
1453
+ else if (diskRatio > this.thresholds.diskRatioInfo) out.push(diskSpilloverFinding(rdd, diskRatio, 'info'));
1454
+ }
1455
+ }
1456
+ }
1457
+ return out;
1458
+ },
1459
+ },
1460
+ {
1461
+ // Non-local task ratio across stage.localityStats (RACK_LOCAL + ANY vs.
1462
+ // all tasks), the other half of dataflint's "Wasted Cores Ratio" alert;
1463
+ // the idle-core half already lives in memoryUtilization's idleCores
1464
+ // variant above. NO_PREF stays in the denominator only: it's what
1465
+ // shuffle-read stages legitimately report with no locality problem.
1466
+ type: 'coreLocality', scope: 'app', order: 103, fixEffort: 'config', version: 1,
1467
+ docAnchor: '#bottleneck-utilization',
1468
+ thresholds: { minTasks: 50, warnRatio: 0.15, critRatio: 0.35 },
1469
+ detect(
1470
+
1471
+ ctx ,
1472
+ ) {
1473
+ const { totalTasks, nonLocalTasks, ratio } = computeCoreLocalityRatio([...ctx.stages.values()]);
1474
+ if (totalTasks == null || totalTasks < this.thresholds.minTasks) return null;
1475
+ // computeCoreLocalityRatio (src/core-locality-ratio.ts) only ever returns
1476
+ // `ratio: null` together with `totalTasks: null` (its EMPTY sentinel sets
1477
+ // both at once); the `totalTasks == null` guard above already rules that
1478
+ // out, so `ratio` is guaranteed non-null here even though the function's
1479
+ // declared return type keeps the two nullable independently.
1480
+ if (ratio < this.thresholds.warnRatio) return null;
1481
+
1482
+ const value = Math.round(ratio * 100);
1483
+ return {
1484
+ type: 'coreLocality', stageId: null,
1485
+ impactBand: ratio >= this.thresholds.critRatio ? 'critical' : 'warning',
1486
+ metric: 'nonLocalRatio', value,
1487
+ // Raw count behind the ratio, for the impact estimator's network-fetch-penalty
1488
+ // figure. Non-null whenever totalTasks is (both come from the same EMPTY sentinel).
1489
+ nonLocalTaskCount: nonLocalTasks ,
1490
+ confidence: 'low',
1491
+ validationRequired: 'The 15%/35% non-local-ratio thresholds (and the 50-task minimum) are unvalidated design-spike values: no external tool publishes an equivalent metric to calibrate against. Confirm against known-good/known-bad real logs before trusting the impact-band split.',
1492
+ recommendation: `${value}% of tasks (${nonLocalTasks }) ran without process- or node-local data placement: check spark.locality.wait settings and executor/data colocation.`,
1493
+ };
1494
+ },
1495
+ },
1496
+ {
1497
+ // Short-lived executors: an executor stood up and torn down before it
1498
+ // could do useful work: wasteful re-provisioning, not normal scale-down.
1499
+ // Reuses the same executorsAdded/executorsRemoved matching logic as
1500
+ // `utilization` above (no new data extraction), but measures lifetime
1501
+ // against a threshold instead of aggregate active-time.
1502
+ type: 'autoscalingChurn', scope: 'app', order: 103, fixEffort: 'config', version: 1,
1503
+ confidence: 'low',
1504
+ thresholds: { shortLivedMs: 120_000, warningPct: 0.30, criticalPct: 0.60, minExecutors: 5 },
1505
+ detect(
1506
+
1507
+
1508
+
1509
+
1510
+ ctx ,
1511
+ ) {
1512
+ const { app, executorsAdded, executorsRemoved } = ctx;
1513
+ if (executorsAdded.length === 0 || app.endTime == null) return null;
1514
+ if (executorsAdded.length < this.thresholds.minExecutors) return null;
1515
+
1516
+ const removedAt = new Map ();
1517
+ for (const ev of executorsRemoved) removedAt.set(ev.executorId, ev.timestamp);
1518
+
1519
+ let shortLivedCount = 0;
1520
+ for (const ev of executorsAdded) {
1521
+ const endedAt = removedAt.has(ev.executorId) ? removedAt.get(ev.executorId) : app.endTime;
1522
+ const lifetime = endedAt - ev.timestamp;
1523
+ if (lifetime < this.thresholds.shortLivedMs) shortLivedCount++;
1524
+ }
1525
+
1526
+ const shortLivedPct = shortLivedCount / executorsAdded.length;
1527
+ const impactBand = shortLivedPct > this.thresholds.criticalPct ? 'critical'
1528
+ : shortLivedPct > this.thresholds.warningPct ? 'warning' : null;
1529
+ if (!impactBand) return null;
1530
+
1531
+ const pct = Math.round(shortLivedPct * 100);
1532
+ return {
1533
+ type: 'autoscalingChurn', stageId: null, impactBand,
1534
+ metric: 'shortLivedExecutorPct', value: pct,
1535
+ // Raw count behind the percentage, for the impact estimator's startup-overhead figure.
1536
+ shortLivedExecutorCount: shortLivedCount,
1537
+ confidence: this.confidence,
1538
+ recommendation: `${pct}% of executors ran for under 2 minutes before being removed. This looks like wasteful re-provisioning rather than normal scale-down; consider raising spark.dynamicAllocation.executorIdleTimeout or widening the minExecutors/maxExecutors bounds to reduce flapping.`,
1539
+ };
1540
+ },
1541
+ },
1542
+ {
1543
+ // Cross-execution relation reuse (repurposed from the old RDD-lineage
1544
+ // heuristic, which surfaced only internal query-engine RDDs on DataFrame/
1545
+ // SQL workloads; see docs/adr/0009-caching-opportunity-relation-reuse.md).
1546
+ // Flags an input relation scanned by two or more SQL executions in one run.
1547
+ type: 'cachingOpportunity', scope: 'app', order: 105, fixEffort: 'code', version: 1,
1548
+ docAnchor: '#bottleneck-utilization',
1549
+ thresholds: { minExecutions: 2 },
1550
+ detect( ctx ) {
1551
+ const sql = ctx.sql;
1552
+ if (!(sql instanceof Map) || sql.size === 0) return null;
1553
+
1554
+
1555
+
1556
+
1557
+
1558
+
1559
+
1560
+
1561
+
1562
+
1563
+
1564
+
1565
+
1566
+
1567
+
1568
+
1569
+ // relationId -> { format, relation, executionIds:Set, executionBytes: Map<execId, bytes> }
1570
+ const byRelation = new Map ();
1571
+ for (const exec of sql.values()) {
1572
+ if (!exec.planTree) continue;
1573
+ // Dedupe relations within one execution (self-joins count once), summing
1574
+ // this execution's read bytes per relation across its scan nodes.
1575
+ const perExec = new Map ();
1576
+ walkPlanTree(exec.planTree, (node) => {
1577
+ const rid = scanRelationId(node.name ?? '', node.detail ?? '');
1578
+ if (!rid) return;
1579
+ const bytesMetric = (node.metrics ?? []).find(m => m.name === FILES_READ_BYTES);
1580
+ perExec.set(rid, (perExec.get(rid) ?? 0) + (bytesMetric ? bytesMetric.value : 0));
1581
+ });
1582
+ for (const [rid, bytes] of perExec) {
1583
+ let agg = byRelation.get(rid);
1584
+ if (!agg) {
1585
+ const colon = rid.indexOf(':');
1586
+ agg = { format: rid.slice(0, colon), relation: rid.slice(colon + 1), executionIds: new Set(), executionBytes: new Map() };
1587
+ byRelation.set(rid, agg);
1588
+ }
1589
+ agg.executionIds.add(exec.id);
1590
+ agg.executionBytes.set(exec.id, (agg.executionBytes.get(exec.id) ?? 0) + bytes);
1591
+ }
1592
+ }
1593
+
1594
+ // fingerprint -> { operator, exampleNode, executionIds:Set, executionBytes:Map<execId,bytes>, ancestorFingerprints:Set<fingerprint>, leafRelationRids:Set<rid> }
1595
+ const byComposite = new Map ();
1596
+ for (const exec of sql.values()) {
1597
+ if (!exec.planTree) continue;
1598
+ const candidates = findCompositeCandidates(exec.planTree);
1599
+ const fingerprintByNode = new Map (candidates.map((c) => [c.node, c.fingerprint]));
1600
+
1601
+ // Dedupe identical fingerprints within this execution (repeated
1602
+ // identical composite counts once, mirroring the leaf perExec dedupe).
1603
+ const perExecComposite = new Map ();
1604
+ for (const c of candidates) {
1605
+ let agg = perExecComposite.get(c.fingerprint);
1606
+ if (!agg) {
1607
+ agg = {
1608
+ operator: c.operator, node: c.node,
1609
+ leafRelationBytes: new Map(),
1610
+ ancestorFingerprints: new Set(
1611
+ c.ancestorNodes.map(n => fingerprintByNode.get(n)).filter((fp) => Boolean(fp)),
1612
+ ),
1613
+ };
1614
+ perExecComposite.set(c.fingerprint, agg);
1615
+ }
1616
+ for (const [rid, bytes] of c.leafRelationBytes) {
1617
+ agg.leafRelationBytes.set(rid, (agg.leafRelationBytes.get(rid) ?? 0) + bytes);
1618
+ }
1619
+ }
1620
+
1621
+ for (const [fingerprint, agg] of perExecComposite) {
1622
+ let cAgg = byComposite.get(fingerprint);
1623
+ if (!cAgg) {
1624
+ cAgg = {
1625
+ operator: agg.operator, exampleNode: agg.node,
1626
+ executionIds: new Set(), executionBytes: new Map(),
1627
+ ancestorFingerprints: new Set(), leafRelationRids: new Set(),
1628
+ };
1629
+ byComposite.set(fingerprint, cAgg);
1630
+ }
1631
+ cAgg.executionIds.add(exec.id);
1632
+ const execBytes = [...agg.leafRelationBytes.values()].reduce((sum, b) => sum + b, 0);
1633
+ cAgg.executionBytes.set(exec.id, (cAgg.executionBytes.get(exec.id) ?? 0) + execBytes);
1634
+ for (const af of agg.ancestorFingerprints) cAgg.ancestorFingerprints.add(af);
1635
+ for (const rid of agg.leafRelationBytes.keys()) cAgg.leafRelationRids.add(rid);
1636
+ }
1637
+ }
1638
+
1639
+ // Qualifying = has enough distinct executions on its own. Nested-dedupe:
1640
+ // if a qualifying composite has a qualifying ANCESTOR composite, it is
1641
+ // subsumed: fully (equal execution sets) or partially (residual).
1642
+ const isQualifying = (fp ) =>
1643
+ byComposite.has(fp) && byComposite.get(fp) .executionIds.size >= this.thresholds.minExecutions;
1644
+ // fingerprint -> { finalExecutionIds:Set, suppressed:boolean }
1645
+ const compositeResolutions = new Map ();
1646
+ for (const [fingerprint, agg] of byComposite) {
1647
+ if (!isQualifying(fingerprint)) { compositeResolutions.set(fingerprint, { finalExecutionIds: agg.executionIds, suppressed: true }); continue; }
1648
+ const qualifyingAncestors = [...agg.ancestorFingerprints].filter(isQualifying).map(fp => byComposite.get(fp) );
1649
+ if (qualifyingAncestors.length === 0) { compositeResolutions.set(fingerprint, { finalExecutionIds: agg.executionIds, suppressed: false }); continue; }
1650
+ const coveredByAncestors = new Set(qualifyingAncestors.flatMap(outer => [...outer.executionIds]));
1651
+ const residual = new Set([...agg.executionIds].filter(id => !coveredByAncestors.has(id)));
1652
+ if (residual.size === 0) compositeResolutions.set(fingerprint, { finalExecutionIds: residual, suppressed: true });
1653
+ else if (residual.size < this.thresholds.minExecutions) compositeResolutions.set(fingerprint, { finalExecutionIds: residual, suppressed: true });
1654
+ else compositeResolutions.set(fingerprint, { finalExecutionIds: residual, suppressed: false });
1655
+ }
1656
+
1657
+ const relationDisplayName = (rid ) => rid.slice(rid.indexOf(':') + 1);
1658
+ const compositeVerb = { join: ['joined', 'join'], union: ['unioned', 'union'] };
1659
+
1660
+ const out = [];
1661
+ // rid -> Set<execId> covered by an emitted composite (for leaf suppression, Task 6)
1662
+ const coveredExecutionsByRid = new Map ();
1663
+ for (const [fingerprint, agg] of byComposite) {
1664
+ const resolution = compositeResolutions.get(fingerprint) ;
1665
+ if (resolution.suppressed) continue;
1666
+ const finalExecutionIds = [...resolution.finalExecutionIds].sort((a, b) => a - b);
1667
+ const totalReadBytes = finalExecutionIds.reduce((sum, id) => sum + (agg.executionBytes.get(id) ?? 0), 0);
1668
+ const rids = [...agg.leafRelationRids].sort();
1669
+ if (rids.length === 0) continue;
1670
+ const relations = rids.map(rid => {
1671
+ const colon = rid.indexOf(':');
1672
+ return { relation: rid.slice(colon + 1), format: rid.slice(0, colon) };
1673
+ });
1674
+ const [verb, connector] = compositeVerb[agg.operator];
1675
+ const relationDisplay = rids.map(relationDisplayName).join(` ${connector} `);
1676
+ const value = finalExecutionIds.length;
1677
+ const recommendation = totalReadBytes >= 128 * MB
1678
+ ? `${verb[0].toUpperCase()}${verb.slice(1)} result read by ${value} queries (~${formatBytes(totalReadBytes)}). Cache/persist the ${verb} DataFrame so it is computed once.`
1679
+ : `${verb[0].toUpperCase()}${verb.slice(1)} result read by ${value} queries. Cache the ${verb} DataFrame, or reconsider whether it needs to be recomputed each time.`;
1680
+
1681
+ out.push({
1682
+ type: 'cachingOpportunity', variant: 'composite', stageId: null, impactBand: 'info',
1683
+ metric: 'executionReuse', value,
1684
+ format: 'derived', relations, operator: agg.operator, relation: relationDisplay,
1685
+ executionIds: finalExecutionIds, totalReadBytes,
1686
+ confidence: 'low',
1687
+ validationRequired:
1688
+ 'Composite reuse is inferred from a structural plan-shape match (operator + normalized ' +
1689
+ 'join/filter condition + child shapes) across SQL executions; confirm these executions ' +
1690
+ 'truly compute the same join/union before caching.',
1691
+ recommendation,
1692
+ });
1693
+
1694
+ for (const rid of rids) {
1695
+ if (!coveredExecutionsByRid.has(rid)) coveredExecutionsByRid.set(rid, new Set());
1696
+ for (const id of finalExecutionIds) coveredExecutionsByRid.get(rid) .add(id);
1697
+ }
1698
+ }
1699
+
1700
+ for (const [rid, agg] of byRelation) {
1701
+ const covered = coveredExecutionsByRid.get(rid);
1702
+ const residualExecutionIds = covered
1703
+ ? [...agg.executionIds].filter(id => !covered.has(id))
1704
+ : [...agg.executionIds];
1705
+ if (residualExecutionIds.length < this.thresholds.minExecutions) continue;
1706
+ const value = residualExecutionIds.length;
1707
+ const totalReadBytes = residualExecutionIds.reduce((sum, id) => sum + (agg.executionBytes.get(id) ?? 0), 0);
1708
+ const recommendation = totalReadBytes >= 128 * MB
1709
+ ? `Read by ${value} queries (~${formatBytes(totalReadBytes)}). Cache/persist the shared DataFrame so it is scanned once.`
1710
+ : `Read by ${value} queries. Cache the shared DataFrame, or broadcast it if it is a small join lookup.`;
1711
+ out.push({
1712
+ type: 'cachingOpportunity', stageId: null, impactBand: 'info',
1713
+ metric: 'executionReuse', value,
1714
+ relation: agg.relation, format: agg.format,
1715
+ executionIds: residualExecutionIds.sort((a, b) => a - b),
1716
+ totalReadBytes,
1717
+ confidence: 'low',
1718
+ validationRequired:
1719
+ 'Relation-reuse is inferred from the pre-AQE plan scan identity across SQL ' +
1720
+ 'executions; confirm the reads are the same data and cacheable within one ' +
1721
+ 'session before acting.',
1722
+ recommendation,
1723
+ });
1724
+ }
1725
+ return out;
1726
+ },
1727
+ },
1728
+ {
1729
+ type: 'jobFailureRate', scope: 'app', order: 110, fixEffort: 'code', version: 1,
1730
+ docAnchor: '#bottleneck-job-failure-rate',
1731
+ thresholds: { infoRate: 0.10, warnRate: 0.30, critRate: 0.50 },
1732
+ detect(
1733
+
1734
+ ctx ,
1735
+ ) {
1736
+ const { jobs, stages } = ctx;
1737
+ const all = jobs ? [...jobs.values()] : [];
1738
+ const completed = all.filter(j => j.result != null);
1739
+ if (completed.length === 0) return null;
1740
+ const failedJobList = completed.filter(j => j.succeeded === false);
1741
+ const failedJobs = failedJobList.length;
1742
+ const rate = failedJobs / completed.length;
1743
+ if (rate < this.thresholds.infoRate) return null;
1744
+ let totalTasks = 0, failedTasks = 0;
1745
+ for (const s of stages.values()) { totalTasks += s.taskCount ?? 0; failedTasks += s.failedTasks ?? 0; }
1746
+ const taskFailureRate = totalTasks > 0 ? failedTasks / totalTasks : 0;
1747
+ // Average wall-clock duration of the failed jobs, for the impact estimator's
1748
+ // cost-only "core-hours burned on work that was thrown away" figure. Jobs
1749
+ // missing either timestamp are excluded rather than counted as zero-length;
1750
+ // with no timed failed job at all the average is 0 (never NaN).
1751
+ const timedFailedJobs = failedJobList.filter(j => j.submissionTime != null && j.completionTime != null);
1752
+ const avgJobDurationMs = timedFailedJobs.length > 0
1753
+ ? timedFailedJobs.reduce((s, j) => s + (j.completionTime - j.submissionTime ), 0) / timedFailedJobs.length
1754
+ : 0;
1755
+ const totalJobs = completed.length;
1756
+ return {
1757
+ type: 'jobFailureRate', stageId: null,
1758
+ impactBand: rate >= this.thresholds.critRate ? 'critical' : rate >= this.thresholds.warnRate ? 'warning' : 'info',
1759
+ metric: 'jobFailureRate', value: Math.round(rate * 1000) / 10,
1760
+ failedJobs, totalJobs, failedTasks, totalTasks, avgJobDurationMs,
1761
+ taskFailureRate: Math.round(taskFailureRate * 1000) / 10,
1762
+ recommendation: `${failedJobs} of ${totalJobs} jobs never recovered: inspect the driver log for the failed job(s) and the stage failures that triggered them.`,
1763
+ };
1764
+ },
1765
+ },
1766
+ // ── Config-sanity entries (scope:'config', inScorecard:false) ────────────────
1767
+ {
1768
+ type: 'configAudit', scope: 'config', order: 120, fixEffort: 'config', version: 1, inScorecard: false,
1769
+ docAnchor: '#config-shuffle-service', thresholds: {}, property: 'spark.shuffle.service.enabled',
1770
+ detect(ctx ) {
1771
+ const res = ctx.app?.resources ?? null;
1772
+ if (res?.dynamicAllocationEnabled === true && res?.shuffleServiceEnabled === false) {
1773
+ return {
1774
+ type: 'configAudit', property: 'spark.shuffle.service.enabled',
1775
+ impactBand: 'warning', metric: 'config', value: 'false',
1776
+ recommendation: 'Dynamic allocation is on but the external shuffle service is off: set spark.shuffle.service.enabled=true so shuffle data survives executor removal.',
1777
+ };
1778
+ }
1779
+ return null;
1780
+ },
1781
+ },
1782
+ {
1783
+ type: 'configAudit', scope: 'config', order: 121, fixEffort: 'config', version: 1, inScorecard: false,
1784
+ docAnchor: '#config-autoscale-bounds', thresholds: {}, property: 'spark.dynamicAllocation.maxExecutors',
1785
+ detect(ctx ) {
1786
+ const app = ctx.app; const config = app?.config ?? {}; const res = app?.resources ?? null;
1787
+ if (res?.dynamicAllocationEnabled !== true) return null;
1788
+ const minN = config['spark.dynamicAllocation.minExecutors'] != null ? parseInt(config['spark.dynamicAllocation.minExecutors'], 10) : null;
1789
+ const maxN = config['spark.dynamicAllocation.maxExecutors'] != null ? parseInt(config['spark.dynamicAllocation.maxExecutors'], 10) : null;
1790
+ if (minN != null && maxN != null && minN > maxN) {
1791
+ return {
1792
+ type: 'configAudit', property: 'spark.dynamicAllocation.minExecutors',
1793
+ impactBand: 'critical', metric: 'config', value: `${minN} > ${maxN}`,
1794
+ recommendation: `Autoscaling bounds are inverted: spark.dynamicAllocation.minExecutors (${minN}) exceeds maxExecutors (${maxN}). Set min ≤ max.`,
1795
+ };
1796
+ }
1797
+ if (maxN == null) {
1798
+ return {
1799
+ type: 'configAudit', property: 'spark.dynamicAllocation.maxExecutors',
1800
+ impactBand: 'info', metric: 'config', value: '(unset)',
1801
+ recommendation: 'Dynamic allocation is on with no upper bound: set spark.dynamicAllocation.maxExecutors to cap cluster growth.',
1802
+ };
1803
+ }
1804
+ return null;
1805
+ },
1806
+ },
1807
+ {
1808
+ type: 'configAudit', scope: 'config', order: 122, fixEffort: 'config', version: 1, inScorecard: false,
1809
+ docAnchor: '#config-serializer', thresholds: {}, property: 'spark.serializer',
1810
+ detect(ctx ) {
1811
+ const app = ctx.app; const config = app?.config ?? {}; const res = app?.resources ?? null;
1812
+ if (Object.keys(config).length === 0) return null;
1813
+ const ser = res?.serializer ?? config['spark.serializer'] ?? null;
1814
+ const isKryo = typeof ser === 'string' && /kryo/i.test(ser);
1815
+ if (isKryo) return null;
1816
+ return {
1817
+ type: 'configAudit', property: 'spark.serializer',
1818
+ impactBand: 'info', metric: 'config', value: ser ?? '(default JavaSerializer)',
1819
+ recommendation: `Current serializer is ${ser ?? 'the default JavaSerializer'}: consider spark.serializer=org.apache.spark.serializer.KryoSerializer for faster, smaller buffers.`,
1820
+ };
1821
+ },
1822
+ },
1823
+ {
1824
+ type: 'configAudit', scope: 'config', order: 123, fixEffort: 'config', version: 1, inScorecard: false,
1825
+ docAnchor: '#config-memory-overhead', thresholds: { floorMB: 384, floorPct: 0.1 }, property: 'spark.executor.memoryOverhead',
1826
+ detect(
1827
+
1828
+ ctx ,
1829
+ ) {
1830
+ const res = ctx.app?.resources ?? null;
1831
+ const memMB = res?.executor?.memoryMB ?? null;
1832
+ const ovMB = res?.executor?.memoryOverheadMB ?? null;
1833
+ if (memMB == null || ovMB == null) return null;
1834
+ const floor = Math.max(this.thresholds.floorMB, Math.round(memMB * this.thresholds.floorPct));
1835
+ if (ovMB >= floor) return null;
1836
+ return {
1837
+ type: 'configAudit', property: 'spark.executor.memoryOverhead',
1838
+ impactBand: 'info', metric: 'config', value: `${ovMB} MiB`,
1839
+ recommendation: `Executor memoryOverhead (${ovMB} MiB) is below Spark's default floor of ${floor} MiB (max of 384 MiB or 10% of executor memory): raise it to avoid off-heap OOM-kills.`,
1840
+ };
1841
+ },
1842
+ },
1843
+ // ── Plan-metric entries (scope:'sql') ────────────────────────────────────
1844
+ {
1845
+ type: 'duplicatePlanSubtree', scope: 'sql', order: 130, fixEffort: 'code', version: 2,
1846
+ docAnchor: '#bottleneck-duplicate-plan-subtree',
1847
+ thresholds: { minSubtreeSize: 3, minOccurrences: 2 },
1848
+ detect(
1849
+
1850
+ sqlExec ,
1851
+ ctx ,
1852
+ ) {
1853
+ if (!sqlExec.planTree) return null;
1854
+ const groups = findDuplicateSubtrees(sqlExec.planTree, this.thresholds);
1855
+ if (groups.length === 0) return null;
1856
+ const fallbackStageIds = stageIdsForSqlExec(sqlExec.id, ctx.stages);
1857
+ return groups.map((g) => {
1858
+ const nodes = [];
1859
+ for (const n of g.nodes) walkPlanTree(n, (node) => nodes.push(node));
1860
+ const stageIds = unionStageIds(nodes, fallbackStageIds);
1861
+ const touching = g.sampleRelation ? ` (touching ${g.sampleRelation})` : '';
1862
+ return {
1863
+ type: 'duplicatePlanSubtree', executionId: sqlExec.id, stageIds,
1864
+ // Fixed fallback: overwritten by deriveImpactBand whenever this finding
1865
+ // gets a real wallClock estimate, which is the common case. Only
1866
+ // surfaces on the rare miss (stage excluded from the occupancy sweep).
1867
+ impactBand: 'warning', metric: 'subtreeOccurrences', value: g.occurrences,
1868
+ rootName: g.rootName, subtreeSize: g.subtreeSize, sampleRelation: g.sampleRelation,
1869
+ groupIndex: g.groupIndex, confidence: 'medium',
1870
+ validationRequired: 'Duplicate-subtree matching compares operator names and metric names only, not literal values or expr IDs: confirm the repeated work is real in the Spark SQL plan tab before acting.',
1871
+ recommendation: g.isExchangeRoot
1872
+ ? `A ${g.subtreeSize}-node subtree rooted at ${pathBasename(g.rootName)} repeats ${g.occurrences}x in this plan${touching}: this looks like a possible missed exchange reuse; check whether the same shuffle could be computed once and reused.`
1873
+ : `A ${g.subtreeSize}-node subtree rooted at ${pathBasename(g.rootName)} repeats ${g.occurrences}x in this plan${touching}: consider caching/persisting the shared computation or check for a duplicated query branch.`,
1874
+ };
1875
+ });
1876
+ },
1877
+ },
1878
+ {
1879
+ type: 'smallFiles', scope: 'sql', order: 131, fixEffort: 'config', version: 2,
1880
+ docAnchor: '#bottleneck-small-files',
1881
+ thresholds: { minFiles: 100, maxAvgFileSizeMB: 3 },
1882
+ detect(
1883
+
1884
+ sqlExec ,
1885
+ ctx ,
1886
+ ) {
1887
+ if (!sqlExec.planTree) return null;
1888
+ const { minFiles, maxAvgFileSizeMB } = this.thresholds;
1889
+
1890
+ const hits = [];
1891
+ walkPlanTree(sqlExec.planTree, (node) => {
1892
+ const metrics = node.metrics ?? [];
1893
+ const byName = (name ) => metrics.find(m => m.name === name);
1894
+ const checkSide = (
1895
+ countMetric ,
1896
+ bytesMetric ,
1897
+ direction ,
1898
+ ) => {
1899
+ if (!countMetric || !bytesMetric || !(countMetric.value > minFiles)) return;
1900
+ const avgBytes = bytesMetric.value / countMetric.value;
1901
+ if (!(avgBytes < maxAvgFileSizeMB * MB)) return;
1902
+ hits.push({ direction, fileCount: countMetric.value, avgBytes, nodeName: node.name, node });
1903
+ };
1904
+ checkSide(byName(FILES_READ_COUNT), byName(FILES_READ_BYTES), 'read');
1905
+ checkSide(byName(FILES_WRITTEN_COUNT), byName(FILES_WRITTEN_BYTES), 'write');
1906
+ });
1907
+ if (hits.length === 0) return null;
1908
+ const fallbackStageIds = stageIdsForSqlExec(sqlExec.id, ctx.stages);
1909
+ return hits.map(h => {
1910
+ const stageIds = unionStageIds([h.node], fallbackStageIds);
1911
+ return {
1912
+ type: 'smallFiles', executionId: sqlExec.id, stageIds,
1913
+ impactBand: 'warning',
1914
+ metric: 'avgFileSizeBytes', value: Math.round(h.avgBytes),
1915
+ fileCount: h.fileCount, direction: h.direction, nodeName: h.nodeName,
1916
+ recommendation: h.direction === 'read'
1917
+ ? `${h.fileCount} small files were read at ${pathBasename(h.nodeName)}: consider compacting the upstream output so fewer, larger files are produced.`
1918
+ : `${h.fileCount} small files were written at ${pathBasename(h.nodeName)}: repartition or coalesce before writing to raise the average file size.`,
1919
+ };
1920
+ });
1921
+ },
1922
+ },
1923
+ {
1924
+ // Entry-level type is an identifier only; it never appears on an
1925
+ // emitted finding. Findings carry their own type ('underBroadcast' or
1926
+ // 'overBroadcast') since one shared plan-walk covers both opposite-
1927
+ // direction rules (dataflint JoinToBroadcastAlert / BroadcastTooLargeAlert).
1928
+ type: 'broadcastSizing', scope: 'sql', order: 132, fixEffort: 'config', version: 2,
1929
+ docAnchor: '#bottleneck-broadcast-sizing',
1930
+ thresholds: {
1931
+ broadcastTiers: [10 * MB, 100 * MB, GB, 5 * GB],
1932
+ comparisonTiers: [10 * GB, 300 * GB, TB],
1933
+ overBroadcastBytes: GB,
1934
+ },
1935
+ detect(
1936
+
1937
+
1938
+
1939
+ sqlExec ,
1940
+ ctx ,
1941
+ ) {
1942
+ if (!sqlExec.planTree) return null;
1943
+ const { broadcastTiers, comparisonTiers, overBroadcastBytes } = this.thresholds;
1944
+ const fallbackStageIds = stageIdsForSqlExec(sqlExec.id, ctx.stages);
1945
+ const out = [];
1946
+ walkPlanTree(sqlExec.planTree, (node) => {
1947
+ if (node.name === 'SortMergeJoin' && (node.children ?? []).length === 2) {
1948
+ const [childA, childB] = node.children;
1949
+ const a = sumBoundarySize(childA);
1950
+ const b = sumBoundarySize(childB);
1951
+ const smaller = Math.min(a, b);
1952
+ const larger = Math.max(a, b);
1953
+ if (smaller > 0) {
1954
+ const fires = smaller < broadcastTiers[0]
1955
+ || (smaller < broadcastTiers[1] && larger > comparisonTiers[0])
1956
+ || (smaller < broadcastTiers[2] && larger > comparisonTiers[1])
1957
+ || (smaller < broadcastTiers[3] && larger > comparisonTiers[2]);
1958
+ if (fires) {
1959
+ const contributors = [...boundarySizeContributors(childA), ...boundarySizeContributors(childB)];
1960
+ out.push({
1961
+ type: 'underBroadcast', executionId: sqlExec.id, stageIds: unionStageIds(contributors, fallbackStageIds),
1962
+ impactBand: 'info', metric: 'smallerSideBytes', value: smaller,
1963
+ largerSideBytes: larger,
1964
+ recommendation: `The smaller input to this Sort Merge Join (${formatBytes(smaller)}) is well under the broadcast threshold relative to the larger side (${formatBytes(larger)}): this could have been a broadcast join. Consider a broadcast() hint or raising spark.sql.autoBroadcastJoinThreshold.`,
1965
+ });
1966
+ }
1967
+ }
1968
+ }
1969
+ if (node.name === 'BroadcastExchange') {
1970
+ const m = (node.metrics ?? []).find(x => x.name === 'data size');
1971
+ if (m && m.value > overBroadcastBytes) {
1972
+ // The BroadcastExchange node's own metrics are computed on the
1973
+ // driver and never appear on any TaskEnd (node.stageIds is
1974
+ // always empty in real data); its child carries the
1975
+ // executor-side metrics, so only the child is unioned in.
1976
+ const child = (node.children ?? [])[0];
1977
+ out.push({
1978
+ type: 'overBroadcast', executionId: sqlExec.id,
1979
+ stageIds: unionStageIds(child ? [child] : [], fallbackStageIds),
1980
+ impactBand: 'warning', metric: 'broadcastBytes', value: m.value,
1981
+ recommendation: `This broadcast (${formatBytes(m.value)}) exceeds the 1 GB threshold: check for a misapplied broadcast hint or a misconfigured spark.sql.autoBroadcastJoinThreshold.`,
1982
+ });
1983
+ }
1984
+ }
1985
+ });
1986
+ return out.length ? out : null;
1987
+ },
1988
+ },
1989
+ ];