sparkforensics-cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/sparkforensics-analyze.mjs +288 -0
- package/package.json +29 -0
- package/vendor-core/analyzer.js +167 -0
- package/vendor-core/assert-never.js +3 -0
- package/vendor-core/cli/budgets.js +203 -0
- package/vendor-core/cli/collect-run.js +107 -0
- package/vendor-core/core-count.js +68 -0
- package/vendor-core/core-locality-ratio.js +54 -0
- package/vendor-core/core-time-series.js +92 -0
- package/vendor-core/core-usage-locality.js +70 -0
- package/vendor-core/detectors.js +1989 -0
- package/vendor-core/docs-config.js +72 -0
- package/vendor-core/docs-site-config.js +23 -0
- package/vendor-core/efficiency-model.js +62 -0
- package/vendor-core/etl-phases.js +28 -0
- package/vendor-core/event-handlers.js +906 -0
- package/vendor-core/event-schemas.js +405 -0
- package/vendor-core/evidence-availability.js +121 -0
- package/vendor-core/evidence-report.js +459 -0
- package/vendor-core/finding-action-label.js +97 -0
- package/vendor-core/finding-filter-predicate.js +38 -0
- package/vendor-core/format-utils.js +167 -0
- package/vendor-core/impact-band.js +50 -0
- package/vendor-core/impact-estimator.js +428 -0
- package/vendor-core/ingest.js +139 -0
- package/vendor-core/job-groups.js +30 -0
- package/vendor-core/load-vendored.js +24 -0
- package/vendor-core/lz4-block.js +135 -0
- package/vendor-core/mcp-error.js +3 -0
- package/vendor-core/mcp-server-factory.js +115 -0
- package/vendor-core/mcp-tools.js +331 -0
- package/vendor-core/model-assembler.js +76 -0
- package/vendor-core/occupancy.js +202 -0
- package/vendor-core/parser-worker.js +249 -0
- package/vendor-core/plan-dot.js +25 -0
- package/vendor-core/plan-duration-attribution.js +185 -0
- package/vendor-core/plan-graph-model.js +171 -0
- package/vendor-core/plan-node-detail.js +159 -0
- package/vendor-core/plan-summary.js +233 -0
- package/vendor-core/plan-tree-walk.js +29 -0
- package/vendor-core/proxy.js +157 -0
- package/vendor-core/recommendation-rollup.js +197 -0
- package/vendor-core/redact.js +175 -0
- package/vendor-core/rolling-log-reassembly.js +52 -0
- package/vendor-core/run-aggregates.js +44 -0
- package/vendor-core/run-comparison.js +458 -0
- package/vendor-core/scaling-sim.js +73 -0
- package/vendor-core/session-snapshot.js +79 -0
- package/vendor-core/shs-fetch.js +196 -0
- package/vendor-core/shs-load.js +121 -0
- package/vendor-core/shs-request.js +101 -0
- package/vendor-core/shs-schemas.js +13 -0
- package/vendor-core/snappy-block.js +140 -0
- package/vendor-core/stage-quantiles.js +199 -0
- package/vendor-core/threshold-summary.js +35 -0
- package/vendor-core/types.js +286 -0
- package/vendor-core/vendor/fflate.js +2695 -0
- package/vendor-core/vendor/fzstd.js +768 -0
- package/vendor-core/wall-clock.js +36 -0
- package/vendor-core/wasted-core-hours.js +68 -0
|
@@ -0,0 +1,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
|
+
];
|