sparkforensics-mcp 0.4.0 → 0.5.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/package.json +1 -1
- package/vendor-core/allocation.js +106 -0
- package/vendor-core/analyzer.js +13 -13
- package/vendor-core/cli/budgets.js +23 -9
- package/vendor-core/cli/collect-run.js +11 -4
- package/vendor-core/cli/regression-budgets.js +83 -0
- package/vendor-core/comparison-verdict.js +22 -22
- package/vendor-core/core-source-hash.txt +1 -1
- package/vendor-core/detectors.js +202 -68
- package/vendor-core/docs-content/detection/cache.md +3 -2
- package/vendor-core/docs-content/detection/cfg.md +9 -8
- package/vendor-core/docs-content/detection/chrn.md +1 -2
- package/vendor-core/docs-content/detection/cold.md +4 -2
- package/vendor-core/docs-content/detection/fail.md +3 -2
- package/vendor-core/docs-content/detection/gc.md +3 -2
- package/vendor-core/docs-content/detection/host.md +2 -1
- package/vendor-core/docs-content/detection/local.md +1 -1
- package/vendor-core/docs-content/detection/mem.md +5 -2
- package/vendor-core/docs-content/detection/plan.md +2 -1
- package/vendor-core/docs-content/detection/sfail.md +2 -1
- package/vendor-core/docs-content/detection/shape.md +5 -4
- package/vendor-core/docs-content/detection/skew.md +3 -1
- package/vendor-core/docs-content/detection/slow.md +2 -2
- package/vendor-core/docs-content/detection/spec.md +2 -3
- package/vendor-core/docs-content/detection/spill.md +1 -1
- package/vendor-core/docs-site-config.js +1 -1
- package/vendor-core/effective-conf.js +107 -0
- package/vendor-core/efficiency-model.js +8 -6
- package/vendor-core/event-handlers.js +160 -47
- package/vendor-core/event-schemas.js +2 -0
- package/vendor-core/evidence-report.js +18 -10
- package/vendor-core/finding-generic-recommendation.js +20 -1
- package/vendor-core/finding-names.js +7 -0
- package/vendor-core/finding-presentation.js +61 -26
- package/vendor-core/finding-tag-help.js +1 -1
- package/vendor-core/finding-types.js +12 -0
- package/vendor-core/format-utils.js +4 -3
- package/vendor-core/impact-estimator.js +20 -2
- package/vendor-core/impact-format.js +14 -13
- package/vendor-core/impact-model.js +27 -5
- package/vendor-core/ingest.js +4 -2
- package/vendor-core/list-runs.js +5 -2
- package/vendor-core/mcp-tools.js +1 -1
- package/vendor-core/model-assembler.js +23 -1
- package/vendor-core/parser-worker.js +1 -1
- package/vendor-core/proxy.js +3 -1
- package/vendor-core/python-stage.js +25 -0
- package/vendor-core/recommendation-rollup.js +16 -9
- package/vendor-core/redact.js +51 -10
- package/vendor-core/remediation.js +20 -0
- package/vendor-core/run-comparison.js +43 -24
- package/vendor-core/run-interpretation.js +2 -1
- package/vendor-core/run-metrics.js +198 -0
- package/vendor-core/run-totals.js +24 -0
- package/vendor-core/run-verdict.js +3 -4
- package/vendor-core/scorecard-estimates.js +1 -0
- package/vendor-core/session-snapshot.js +7 -0
- package/vendor-core/shs-schemas.js +2 -2
- package/vendor-core/spark-memory.js +17 -0
- package/vendor-core/stage-plan-nodes.js +18 -0
- package/vendor-core/stage-quantiles.js +4 -0
- package/vendor-core/types.js +49 -1
- package/vendor-core/wasted-core-hours.js +10 -7
- package/vendor-core/write-targets.js +312 -0
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
// detectors.ts is type-only, so it is erased at build time. A detector's scope, order and emits
|
|
9
9
|
// list stay on its DETECTORS entry; renderers get them through detectorInfoByType().
|
|
10
10
|
|
|
11
|
+
|
|
11
12
|
|
|
12
13
|
|
|
13
14
|
|
|
@@ -26,6 +27,28 @@
|
|
|
26
27
|
|
|
27
28
|
|
|
28
29
|
|
|
30
|
+
/** A share threshold as captions and caveats state it: 0.005 -> "0.5%", never float noise like 7.000000000000001%. */
|
|
31
|
+
export const shareLabel = (share ) => `${Math.round(share * 1e6) / 1e4}%`;
|
|
32
|
+
|
|
33
|
+
// Whether the detector found `key` already logged on for this run. Its switchFix (detectors.ts)
|
|
34
|
+
// then worded the row's own text for that case and left the property out of the remediation, so
|
|
35
|
+
// a generic line reads the same decision and never recommends a switch the row says is on.
|
|
36
|
+
// A finding with no remediation (older or hand-built data) keeps the property wording.
|
|
37
|
+
function switchAlreadyOn(finding , key ) {
|
|
38
|
+
return finding.remediation != null && !finding.remediation.some((r) => r.key === key);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const SKEW_JOIN_KEY = 'spark.sql.adaptive.skewJoin.enabled';
|
|
42
|
+
const SKEW_JOIN_ALREADY_ON = 'AQE skew-join handling is already on, so salt the key or repartition on a better key.';
|
|
43
|
+
const SKEW_JOIN_AQE_OFF = 'AQE is off, so enable it (spark.sql.adaptive.enabled) for skew-join handling to apply; otherwise salt the key or repartition on a better key.';
|
|
44
|
+
|
|
45
|
+
// The skew-join generic line, worded per the row's remediation: AQE logged off, switch already on, or neither.
|
|
46
|
+
function skewJoinGeneric(f , unset ) {
|
|
47
|
+
if (f.remediation?.some((r) => r.key === 'spark.sql.adaptive.enabled')) return SKEW_JOIN_AQE_OFF;
|
|
48
|
+
return switchAlreadyOn(f, SKEW_JOIN_KEY) ? SKEW_JOIN_ALREADY_ON : unset;
|
|
49
|
+
}
|
|
50
|
+
const DYNAMIC_ALLOCATION_KEY = 'spark.dynamicAllocation.enabled';
|
|
51
|
+
|
|
29
52
|
// The four configAudit DETECTORS entries share this row, one per audited property.
|
|
30
53
|
const CONFIG_AUDIT_PRESENTATION = {
|
|
31
54
|
name: 'config audit',
|
|
@@ -58,7 +81,7 @@ export const FINDING_PRESENTATION
|
|
|
58
81
|
incompleteRun: {
|
|
59
82
|
name: 'incomplete run',
|
|
60
83
|
tag: 'INCMP',
|
|
61
|
-
thresholdSummary: () => 'an event log missing its terminal ApplicationEnd
|
|
84
|
+
thresholdSummary: () => 'an event log missing its terminal ApplicationEnd event',
|
|
62
85
|
actionLabel: () => undefined,
|
|
63
86
|
genericRecommendation: () => undefined,
|
|
64
87
|
},
|
|
@@ -66,14 +89,15 @@ export const FINDING_PRESENTATION
|
|
|
66
89
|
skew: {
|
|
67
90
|
name: 'task skew',
|
|
68
91
|
tag: 'SKEW',
|
|
69
|
-
thresholdSummary: () =>
|
|
92
|
+
thresholdSummary: (t) => `P95 task time over ${t.ratioWarn}× the median (the longest task on stages under ${t.minTasksForP95} tasks)`,
|
|
70
93
|
actionLabel: () => 'Fix task skew',
|
|
71
|
-
genericRecommendation: () =>
|
|
94
|
+
genericRecommendation: (f) => skewJoinGeneric(f,
|
|
95
|
+
'For join-driven skew, enable AQE skew-join handling (spark.sql.adaptive.skewJoin.enabled); otherwise salt the key or repartition on a better key.'),
|
|
72
96
|
},
|
|
73
97
|
stageShape: {
|
|
74
98
|
name: 'stage shape',
|
|
75
99
|
tag: 'SHAPE',
|
|
76
|
-
thresholdSummary: () =>
|
|
100
|
+
thresholdSummary: (t) => `under ${t.pRatioMax} tasks per core, output over ${t.oiRatioMax}× input, or one task spanning over ${Math.round(t.stageShareMin * 100)}% of the stage's wall-clock at over ${t.skewWarn}× the median task`,
|
|
77
101
|
actionLabel(f) {
|
|
78
102
|
switch (f.rule) {
|
|
79
103
|
case 'lowParallelism': return 'Increase parallelism';
|
|
@@ -94,7 +118,7 @@ export const FINDING_PRESENTATION
|
|
|
94
118
|
tinyTask: {
|
|
95
119
|
name: 'tiny tasks',
|
|
96
120
|
tag: 'TINY',
|
|
97
|
-
thresholdSummary: () =>
|
|
121
|
+
thresholdSummary: (t) => `${t.minTasks}+ tasks with a median of ${t.maxP50}ms or less and a P95 of ${t.maxP95}ms or less`,
|
|
98
122
|
actionLabel: () => 'Coalesce small tasks',
|
|
99
123
|
// The shuffle-vs-no-shuffle fix isn't a Finding field, so one sentence covers both.
|
|
100
124
|
genericRecommendation: () => 'Scheduler overhead may dominate: lower spark.sql.shuffle.partitions, or coalesce down to fewer, larger tasks.',
|
|
@@ -103,14 +127,14 @@ export const FINDING_PRESENTATION
|
|
|
103
127
|
shuffle: {
|
|
104
128
|
name: 'shuffle I/O',
|
|
105
129
|
tag: 'SHFL',
|
|
106
|
-
thresholdSummary: () =>
|
|
130
|
+
thresholdSummary: (t) => `stage shuffle read above ${t.minBytes / 1048576} MiB`,
|
|
107
131
|
actionLabel: () => 'Reduce shuffle size',
|
|
108
|
-
genericRecommendation: () => '
|
|
132
|
+
genericRecommendation: () => 'Raise spark.sql.shuffle.partitions, or use a broadcast join for the smaller side.',
|
|
109
133
|
},
|
|
110
134
|
partitionSizing: {
|
|
111
135
|
name: 'partition sizing',
|
|
112
136
|
tag: 'PART',
|
|
113
|
-
thresholdSummary: () =>
|
|
137
|
+
thresholdSummary: (t) => `a shuffle partition over ${t.skewRatio}× the median or over ${t.maxPartBytes / 1073741824} GiB, or ${t.lowParTotalBytes / 1073741824} GiB of shuffle on ${t.lowParMaxTasks} tasks or fewer`,
|
|
114
138
|
actionLabel(f) {
|
|
115
139
|
switch (f.rule) {
|
|
116
140
|
case 'shufflePartitionSkew': return 'Fix skewed partition';
|
|
@@ -121,8 +145,10 @@ export const FINDING_PRESENTATION
|
|
|
121
145
|
},
|
|
122
146
|
genericRecommendation(f) {
|
|
123
147
|
switch (f.rule) {
|
|
124
|
-
case 'shufflePartitionSkew': return 'For join skew, enable AQE skew-join handling (spark.sql.adaptive.skewJoin.enabled); otherwise salt the key or repartition on a better key.';
|
|
125
|
-
case 'lowShuffleParallelism': return '
|
|
148
|
+
case 'shufflePartitionSkew': return skewJoinGeneric(f, 'For join skew, enable AQE skew-join handling (spark.sql.adaptive.skewJoin.enabled); otherwise salt the key or repartition on a better key.');
|
|
149
|
+
case 'lowShuffleParallelism': return switchAlreadyOn(f, 'spark.sql.shuffle.partitions')
|
|
150
|
+
? "spark.sql.shuffle.partitions is already high enough, so raise this stage's own partition count (its repartition(n) or RDD parallelism) so each partition is smaller."
|
|
151
|
+
: 'Raise spark.sql.shuffle.partitions so each partition is smaller.';
|
|
126
152
|
case 'maxPartitionTooBig': return 'Repartition to break up the oversized partition before this stage.';
|
|
127
153
|
}
|
|
128
154
|
return undefined;
|
|
@@ -141,7 +167,7 @@ export const FINDING_PRESENTATION
|
|
|
141
167
|
gc: {
|
|
142
168
|
name: 'GC pressure',
|
|
143
169
|
tag: 'GC',
|
|
144
|
-
thresholdSummary: () =>
|
|
170
|
+
thresholdSummary: (t) => `GC above ${t.warnPct100}% (or below ${t.lowInfoPct100}%) of executor run time`,
|
|
145
171
|
actionLabel: (f) => (f.direction === 'low' ? 'Right-size executor memory' : 'Reduce GC pressure'),
|
|
146
172
|
genericRecommendation: (f) => (f.direction === 'low'
|
|
147
173
|
? 'Memory may be over-provisioned here: consider reducing spark.executor.memory for cost savings.'
|
|
@@ -158,14 +184,14 @@ export const FINDING_PRESENTATION
|
|
|
158
184
|
failures: {
|
|
159
185
|
name: 'failed tasks',
|
|
160
186
|
tag: 'FAIL',
|
|
161
|
-
thresholdSummary: () =>
|
|
187
|
+
thresholdSummary: (t) => `over ${shareLabel(t.warnRate)} of a stage's tasks failing`,
|
|
162
188
|
actionLabel: () => 'Investigate task failures',
|
|
163
189
|
genericRecommendation: () => 'Investigate driver logs for executor instability or data-driven errors.',
|
|
164
190
|
},
|
|
165
191
|
retryWaste: {
|
|
166
192
|
name: 'retry waste',
|
|
167
193
|
tag: 'RETRY',
|
|
168
|
-
thresholdSummary: () =>
|
|
194
|
+
thresholdSummary: (t) => `${t.minWasted}+ retried attempts wasting at least ${t.minWasteMs / 1000}s`,
|
|
169
195
|
actionLabel: () => 'Investigate retry cause',
|
|
170
196
|
genericRecommendation: () => 'Investigate executor loss or fetch failures behind the retried attempts.',
|
|
171
197
|
},
|
|
@@ -173,7 +199,7 @@ export const FINDING_PRESENTATION
|
|
|
173
199
|
slowHost: {
|
|
174
200
|
name: 'slow executor host',
|
|
175
201
|
tag: 'HOST',
|
|
176
|
-
thresholdSummary: (t) => `a host
|
|
202
|
+
thresholdSummary: (t) => `a host ${t.ratioWarn}× slower than its peers by mean task time (per-executor figures from ${t.ratioTiers[0]}×)`,
|
|
177
203
|
actionLabel(f) {
|
|
178
204
|
if (f.variant === 'durationShare') return 'Fix data locality';
|
|
179
205
|
if (f.variant === 'multiDim') return 'Investigate degraded executor';
|
|
@@ -182,7 +208,10 @@ export const FINDING_PRESENTATION
|
|
|
182
208
|
genericRecommendation(f) {
|
|
183
209
|
if (f.variant === 'durationShare') return 'Check for data locality or partition assignment skewing work onto one node.';
|
|
184
210
|
if (f.variant === 'multiDim') return 'Investigate uneven partition assignment or a degraded executor.';
|
|
185
|
-
|
|
211
|
+
const check = 'Check what this host was running: it may just hold data locality for its tasks or carry one heavy stage, rather than a hardware fault.';
|
|
212
|
+
return switchAlreadyOn(f, 'spark.speculation')
|
|
213
|
+
? `${check} Speculation is already on, so a lagging task there is already relaunched.`
|
|
214
|
+
: `${check} Enable spark.speculation to relaunch a lagging task automatically.`;
|
|
186
215
|
},
|
|
187
216
|
},
|
|
188
217
|
stageSlowness: {
|
|
@@ -202,14 +231,14 @@ export const FINDING_PRESENTATION
|
|
|
202
231
|
speculationWaste: {
|
|
203
232
|
name: 'speculation waste',
|
|
204
233
|
tag: 'SPEC',
|
|
205
|
-
thresholdSummary: () =>
|
|
234
|
+
thresholdSummary: (t) => `${t.minWasted}+ discarded speculative attempts wasting at least ${t.minWasteMs / 1000}s`,
|
|
206
235
|
actionLabel: () => 'Tune speculation settings',
|
|
207
236
|
genericRecommendation: () => 'If task durations are naturally variable rather than genuine stragglers, consider tuning spark.speculation.multiplier/quantile.',
|
|
208
237
|
},
|
|
209
238
|
coldStart: {
|
|
210
239
|
name: 'cold start',
|
|
211
240
|
tag: 'COLD',
|
|
212
|
-
thresholdSummary: () =>
|
|
241
|
+
thresholdSummary: (t) => `the first stage waiting over ${t.gapSeconds}s for an executor`,
|
|
213
242
|
actionLabel: () => 'Pre-warm cluster',
|
|
214
243
|
genericRecommendation: () => 'Keep a warm pool of idle executors, or if using dynamic allocation, raise the minimum/initial executor count so it does not scale up from zero.',
|
|
215
244
|
},
|
|
@@ -230,7 +259,9 @@ export const FINDING_PRESENTATION
|
|
|
230
259
|
},
|
|
231
260
|
genericRecommendation(f) {
|
|
232
261
|
switch (f.variant) {
|
|
233
|
-
case 'idleCores': return
|
|
262
|
+
case 'idleCores': return switchAlreadyOn(f, DYNAMIC_ALLOCATION_KEY)
|
|
263
|
+
? 'Dynamic allocation is already on, so reduce cluster size.'
|
|
264
|
+
: 'Reduce cluster size or enable dynamic allocation.';
|
|
234
265
|
case 'wasteModel': return 'Review spark.executor.memory and executor count.';
|
|
235
266
|
case 'memoryBand':
|
|
236
267
|
if (f.dataUnavailable) return undefined;
|
|
@@ -244,16 +275,18 @@ export const FINDING_PRESENTATION
|
|
|
244
275
|
utilization: {
|
|
245
276
|
name: 'executor utilization',
|
|
246
277
|
tag: 'UTIL',
|
|
247
|
-
thresholdSummary: () =>
|
|
278
|
+
thresholdSummary: (t) => `average executor utilization below ${shareLabel(t.minUtil)}`,
|
|
248
279
|
actionLabel: () => 'Reduce cluster size',
|
|
249
|
-
genericRecommendation: () =>
|
|
280
|
+
genericRecommendation: (f) => (switchAlreadyOn(f, DYNAMIC_ALLOCATION_KEY)
|
|
281
|
+
? 'Dynamic allocation is already on, so consider reducing cluster size.'
|
|
282
|
+
: 'Consider reducing cluster size or enabling dynamic allocation.'),
|
|
250
283
|
},
|
|
251
284
|
coreLocality: {
|
|
252
285
|
name: 'core locality',
|
|
253
286
|
tag: 'LOCAL',
|
|
254
287
|
thresholdSummary: () => 'task placement missing data-local core assignment',
|
|
255
288
|
actionLabel: () => 'Fix data locality',
|
|
256
|
-
genericRecommendation: () => 'Check
|
|
289
|
+
genericRecommendation: () => 'Check executor/data colocation.',
|
|
257
290
|
},
|
|
258
291
|
cachingOpportunity: {
|
|
259
292
|
name: 'caching opportunity',
|
|
@@ -280,14 +313,14 @@ export const FINDING_PRESENTATION
|
|
|
280
313
|
jobFailureRate: {
|
|
281
314
|
name: 'job failure rate',
|
|
282
315
|
tag: 'JOBS',
|
|
283
|
-
thresholdSummary: () =>
|
|
316
|
+
thresholdSummary: (t) => `at least ${shareLabel(t.infoRate)} of jobs failing`,
|
|
284
317
|
actionLabel: () => 'Investigate failed jobs',
|
|
285
318
|
genericRecommendation: () => 'Inspect the driver log for the failed job(s) and the stage failures that triggered them.',
|
|
286
319
|
},
|
|
287
320
|
autoscalingChurn: {
|
|
288
321
|
name: 'autoscaling churn',
|
|
289
322
|
tag: 'CHRN',
|
|
290
|
-
thresholdSummary: () =>
|
|
323
|
+
thresholdSummary: (t) => `over ${shareLabel(t.warningPct)} of executors living under ${t.shortLivedMs / 60000} minutes`,
|
|
291
324
|
actionLabel: () => 'Reduce autoscaling churn',
|
|
292
325
|
genericRecommendation: () => '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.',
|
|
293
326
|
},
|
|
@@ -305,7 +338,7 @@ export const FINDING_PRESENTATION
|
|
|
305
338
|
smallFiles: {
|
|
306
339
|
name: 'small files',
|
|
307
340
|
tag: 'PLAN',
|
|
308
|
-
thresholdSummary: () =>
|
|
341
|
+
thresholdSummary: (t) => `over ${t.minFiles} files averaging under ${t.maxAvgFileSizeMB} MiB`,
|
|
309
342
|
actionLabel: (f) => (f.direction === 'write' ? 'Coalesce output files' : 'Compact small files'),
|
|
310
343
|
genericRecommendation: (f) => (f.direction === 'write'
|
|
311
344
|
? 'Repartition or coalesce before writing to raise the average file size.'
|
|
@@ -321,9 +354,11 @@ export const FINDING_PRESENTATION
|
|
|
321
354
|
overBroadcast: {
|
|
322
355
|
name: 'oversized broadcast join',
|
|
323
356
|
tag: 'PLAN',
|
|
324
|
-
thresholdSummary: () =>
|
|
357
|
+
thresholdSummary: (t) => `a broadcast over ${t.overBroadcastBytes / 1073741824} GiB`,
|
|
325
358
|
actionLabel: () => 'Fix oversized broadcast',
|
|
326
|
-
genericRecommendation: () => '
|
|
359
|
+
genericRecommendation: (f) => (switchAlreadyOn(f, 'spark.sql.autoBroadcastJoinThreshold')
|
|
360
|
+
? 'Automatic broadcast is already disabled, so remove the broadcast() hint that forced it.'
|
|
361
|
+
: 'Check for a misapplied broadcast hint or a misconfigured spark.sql.autoBroadcastJoinThreshold.'),
|
|
327
362
|
},
|
|
328
363
|
};
|
|
329
364
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Plain-language help per finding tag: the expansion the tag stands for and a one-line description
|
|
2
|
-
// of what it means. Shared by the dashboard (tag tooltips
|
|
2
|
+
// of what it means. Shared by the dashboard (tag tooltips) and the
|
|
3
3
|
// comparison verdict, which names finding categories by their expansion on every path.
|
|
4
4
|
|
|
5
5
|
|
|
@@ -10,6 +10,16 @@
|
|
|
10
10
|
|
|
11
11
|
|
|
12
12
|
|
|
13
|
+
/** One concrete change a finding's fix makes, alongside its prose `recommendation`. Only a Spark
|
|
14
|
+
* property the detector already names; `suggested` is null when it computes no value. Sizes carry a
|
|
15
|
+
* Spark unit suffix ("384m"), counts are plain numbers, switches are booleans. */
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
|
|
13
23
|
// The columns every finding carries, whatever its detector.
|
|
14
24
|
|
|
15
25
|
|
|
@@ -22,6 +32,8 @@
|
|
|
22
32
|
|
|
23
33
|
|
|
24
34
|
|
|
35
|
+
|
|
36
|
+
|
|
25
37
|
|
|
26
38
|
|
|
27
39
|
|
|
@@ -159,9 +159,9 @@ const FINDING_METRIC_UNIT
|
|
|
159
159
|
speculationWasteMs: 'ms', retryWasteMs: 'ms', taskDurationP50: 'ms',
|
|
160
160
|
stageDurationMinutes: 'minutes',
|
|
161
161
|
'P95/median': 'ratio', 'max/median': 'ratio', pRatio: 'ratio', oiRatio: 'ratio',
|
|
162
|
-
|
|
162
|
+
hostMeanRatio: 'ratio', execMaxMedianRatio: 'ratio',
|
|
163
163
|
gcPct: 'pct', failureRate: 'pct', stragglerShare: 'pct',
|
|
164
|
-
hostDurationShare: 'pctFraction',
|
|
164
|
+
hostDurationShare: 'pctFraction', taskStageSkew: 'pctFraction',
|
|
165
165
|
taskCount: 'count', speculativeTasks: 'count', subtreeOccurrences: 'count',
|
|
166
166
|
};
|
|
167
167
|
|
|
@@ -207,7 +207,8 @@ export function formatDuration(ms ) {
|
|
|
207
207
|
if (!ms || ms <= 0) return '—';
|
|
208
208
|
if (ms < 1000) return `${Math.round(ms)}ms`;
|
|
209
209
|
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
|
|
210
|
-
return `${Math.floor(ms / 60000)}m ${Math.floor((ms % 60000) / 1000)}s`;
|
|
210
|
+
if (ms < 3600000) return `${Math.floor(ms / 60000)}m ${Math.floor((ms % 60000) / 1000)}s`;
|
|
211
|
+
return `${Math.floor(ms / 3600000)}h ${Math.floor((ms % 3600000) / 60000)}m`;
|
|
211
212
|
}
|
|
212
213
|
|
|
213
214
|
export function recommendPartitions(stage ) {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
|
|
2
2
|
import { ENTRY_BY_TYPE } from './detectors.js';
|
|
3
|
-
|
|
3
|
+
import { coreTimeFor, } from './impact-model.js';
|
|
4
4
|
|
|
5
5
|
|
|
6
6
|
|
|
@@ -9,7 +9,25 @@ import { ENTRY_BY_TYPE } from './detectors.js';
|
|
|
9
9
|
export function estimateImpact(findings , ctx ) {
|
|
10
10
|
for (const f of findings) {
|
|
11
11
|
const estimate = ENTRY_BY_TYPE.get(f.type)?.estimate(f, ctx);
|
|
12
|
-
if (estimate)
|
|
12
|
+
if (!estimate) continue;
|
|
13
|
+
f.impactEstimate = { ...estimate, coreTimeMs: coreTimeFor(f, estimate) };
|
|
13
14
|
}
|
|
15
|
+
countTailCoreTimeOnce(findings);
|
|
14
16
|
return findings;
|
|
15
17
|
}
|
|
18
|
+
|
|
19
|
+
// skew and straggler claim the same slow tail of a stage, so each reports its removed task time:
|
|
20
|
+
// summed over a stage's findings that would count the tail twice. skew keeps the figure; a
|
|
21
|
+
// straggler on the same stage carries null, since its tail is already counted there.
|
|
22
|
+
const TAIL_CORE_TIME_ORDER = ['skew', 'straggler'];
|
|
23
|
+
|
|
24
|
+
function countTailCoreTimeOnce(findings ) {
|
|
25
|
+
const counted = new Set ();
|
|
26
|
+
const tails = findings
|
|
27
|
+
.filter((f) => f.stageId != null && f.impactEstimate?.coreTimeMs != null && TAIL_CORE_TIME_ORDER.includes(f.type))
|
|
28
|
+
.sort((a, b) => TAIL_CORE_TIME_ORDER.indexOf(a.type) - TAIL_CORE_TIME_ORDER.indexOf(b.type));
|
|
29
|
+
for (const f of tails) {
|
|
30
|
+
if (counted.has(f.stageId )) f.impactEstimate .coreTimeMs = null;
|
|
31
|
+
else counted.add(f.stageId );
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -18,12 +18,13 @@ export function impactFigure(finding ) {
|
|
|
18
18
|
return null;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
/** What a raw-waste figure counts, by its unit, as the words that follow it
|
|
22
|
-
|
|
23
|
-
|
|
21
|
+
/** What a raw-waste figure counts, by its unit, as the words that follow it: an idle core
|
|
22
|
+
* figure is capacity no task ran on, not core time. */
|
|
23
|
+
export function rawWasteMeaning(rawWaste ) {
|
|
24
|
+
switch (rawWaste?.unit) {
|
|
24
25
|
case 'mbSeconds': return 'of unused executor memory';
|
|
25
26
|
case 'coreHours':
|
|
26
|
-
case 'coreMs': return 'of core time';
|
|
27
|
+
case 'coreMs': return rawWaste.idle ? 'of idle core capacity' : 'of core time';
|
|
27
28
|
case 'bytes': return 'of extra data written';
|
|
28
29
|
case 'ms': return 'of task time';
|
|
29
30
|
default: return null;
|
|
@@ -39,7 +40,7 @@ export function savingsMeaning(finding ) {
|
|
|
39
40
|
const estimate = finding.impactEstimate;
|
|
40
41
|
if (!estimate) return null;
|
|
41
42
|
if (estimate.wallClock) return 'of run time';
|
|
42
|
-
return rawWasteMeaning(estimate.rawWaste
|
|
43
|
+
return rawWasteMeaning(estimate.rawWaste);
|
|
43
44
|
}
|
|
44
45
|
|
|
45
46
|
/** A finding's "Potential savings" figure as the widget board shows it: the
|
|
@@ -54,7 +55,7 @@ export function impactEstimateFigure(estimate )
|
|
|
54
55
|
return { text: formatWallClockRange(estimate.wallClock .low, estimate.wallClock .high), meaning: 'of run time' };
|
|
55
56
|
}
|
|
56
57
|
const rawWasteText = estimate.rawWaste && estimate.rawWaste.value > 0 ? formatRawWaste(estimate.rawWaste) : null;
|
|
57
|
-
if (rawWasteText && !readsAsZero(rawWasteText)) return { text: rawWasteText, meaning: rawWasteMeaning(estimate.rawWaste
|
|
58
|
+
if (rawWasteText && !readsAsZero(rawWasteText)) return { text: rawWasteText, meaning: rawWasteMeaning(estimate.rawWaste) };
|
|
58
59
|
return null;
|
|
59
60
|
}
|
|
60
61
|
|
|
@@ -84,16 +85,17 @@ export function impactEstimateCompact(estimate )
|
|
|
84
85
|
* and the raw waste behind it. Null when the finding carries no estimate
|
|
85
86
|
* model (`estimateMethod: 'none'`), no estimate at all, or a figure that
|
|
86
87
|
* reads as zero (the step shows no savings then either). Uses the same
|
|
87
|
-
* formatting and zero rules as the step's own savings figure
|
|
88
|
+
* formatting and zero rules as the step's own savings figure, which it does
|
|
89
|
+
* not repeat: the step already shows it. */
|
|
88
90
|
export function estimateProvenance(finding ) {
|
|
89
91
|
const estimate = finding.impactEstimate;
|
|
90
92
|
if (!estimate || estimate.estimateMethod === 'none') return null;
|
|
91
|
-
const method = estimate.estimateMethod
|
|
93
|
+
const method = `${estimate.estimateMethod[0].toUpperCase()}${estimate.estimateMethod.slice(1)}`;
|
|
92
94
|
const rawWaste = estimate.rawWaste && estimate.rawWaste.value > 0 ? estimate.rawWaste : null;
|
|
93
95
|
const raw = rawWaste && !readsAsZero(formatRawWaste(rawWaste)) ? formatRawWaste(rawWaste) : null;
|
|
94
96
|
const wallClock = estimate.wallClock;
|
|
95
97
|
if (estimate.basis === 'resourceOnly') {
|
|
96
|
-
return raw ?
|
|
98
|
+
return raw ? `${method}; ${raw} wasted, which may not shorten the run.` : null;
|
|
97
99
|
}
|
|
98
100
|
if (!wallClock || wallClock.high <= 0) return null;
|
|
99
101
|
const highText = formatWallClockRange(wallClock.high, wallClock.high);
|
|
@@ -102,13 +104,12 @@ export function estimateProvenance(finding )
|
|
|
102
104
|
if (raw && rawWaste .unit !== 'ms') rawNote = ` Resource waste measured: ${raw}.`;
|
|
103
105
|
else if (raw && rawWaste .value > wallClock.high && raw !== highText) rawNote = ` Raw waste before the floor clipped it: ${raw}.`;
|
|
104
106
|
if (estimate.basis === 'serial') {
|
|
105
|
-
return `${
|
|
107
|
+
return `${method}; the stage ran alone, so this is close to a point estimate.${rawNote}`;
|
|
106
108
|
}
|
|
107
109
|
if (estimate.basis === 'contended') {
|
|
108
110
|
const lowText = formatWallClockRange(wallClock.low, wallClock.low);
|
|
109
|
-
const
|
|
110
|
-
|
|
111
|
-
return `${range}, ${method}. The stage shared the cluster with others: ${spread}.${rawNote}`;
|
|
111
|
+
const spread = lowText === highText ? 'its floor and high agree' : `${lowText} is the floor, ${highText} if the fix fully lands`;
|
|
112
|
+
return `${method}; the stage shared the cluster: ${spread}.${rawNote}`;
|
|
112
113
|
}
|
|
113
114
|
return null;
|
|
114
115
|
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// The waste models every detector entry's estimate() builds its ImpactEstimate from: the assumed
|
|
2
2
|
// throughputs, the per-stage measurements behind them and the occupancy clip wrappers. Each
|
|
3
3
|
// finding type's own composition of these lives on its DETECTORS entry, next to its detect().
|
|
4
|
-
|
|
4
|
+
|
|
5
|
+
import { isPythonStage } from './python-stage.js';
|
|
5
6
|
import { nsToMs } from './format-utils.js';
|
|
6
7
|
import {
|
|
7
8
|
estimateSingleStage, estimateMultiStage,
|
|
@@ -16,6 +17,8 @@ import {
|
|
|
16
17
|
|
|
17
18
|
|
|
18
19
|
|
|
20
|
+
|
|
21
|
+
|
|
19
22
|
|
|
20
23
|
|
|
21
24
|
// Assumed shuffle-network throughput per executor link, ~1 Gbps. Starting assumption, unvalidated.
|
|
@@ -104,13 +107,14 @@ const IDLE_CPU_SHARE_MAX = 0.01;
|
|
|
104
107
|
|
|
105
108
|
// True when the stage's tasks spent under IDLE_CPU_SHARE_MAX of their run time on CPU. False when
|
|
106
109
|
// the share can't be trusted: no CPU time recorded (older Spark logs omit the metric), or Python
|
|
107
|
-
// code run through
|
|
108
|
-
//
|
|
109
|
-
|
|
110
|
+
// code run through a Python worker (isPythonStage: a PythonRDD stage or a Python UDF operator in
|
|
111
|
+
// its SQL plan), whose worker-process CPU executorCpuTime (the JVM task thread's) never counts
|
|
112
|
+
// (such stages read 0.1% on the same logs while computing).
|
|
113
|
+
export function tasksMostlyIdle(stage , sql = new Map()) {
|
|
110
114
|
const runMs = stage.executorRunTime ?? 0;
|
|
111
115
|
const cpuMs = nsToMs(stage.executorCpuTime ?? 0);
|
|
112
116
|
if (runMs <= 0 || cpuMs <= 0) return false;
|
|
113
|
-
if (
|
|
117
|
+
if (isPythonStage(stage, sql)) return false;
|
|
114
118
|
return cpuMs / runMs < IDLE_CPU_SHARE_MAX;
|
|
115
119
|
}
|
|
116
120
|
|
|
@@ -173,3 +177,21 @@ export function stageMappableWasteOrCostOnly(
|
|
|
173
177
|
// null: every stage excluded from the sweep
|
|
174
178
|
return multiStageImpact(stageIds, wasteMsByStage, ctx, 'modeled', rawWaste) ?? costOnly('modeled', rawWaste);
|
|
175
179
|
}
|
|
180
|
+
|
|
181
|
+
// Finding types whose raw figure is busy core time read straight from the log: gc's jvmGCTime
|
|
182
|
+
// (coreMs) and the discarded speculative or retried attempts' run time ('ms' cross-task sums).
|
|
183
|
+
// Any other core figure is idle capacity, or modeled on an assumed constant (coreLocality's
|
|
184
|
+
// per-task fetch penalty, autoscalingChurn's executor-hours, jobFailureRate's job-hours).
|
|
185
|
+
const MEASURED_CORE_TIME_FIGURE = new Set(['gc', 'retryWaste', 'speculationWaste']);
|
|
186
|
+
|
|
187
|
+
/** The busy core time a finding's fix removes, in core-milliseconds, or null when the detector
|
|
188
|
+
* measures none. Only a measured figure counts: one its estimate() already set (skew and
|
|
189
|
+
* straggler's removed task time), or the raw figure of a MEASURED_CORE_TIME_FIGURE type. A
|
|
190
|
+
* wall-clock claim, an idle capacity figure and a modeled figure are never converted. It never
|
|
191
|
+
* reads executorCpuTime, which leaves out Python worker CPU. */
|
|
192
|
+
export function coreTimeFor(finding , estimate ) {
|
|
193
|
+
if (estimate.coreTimeMs !== undefined) return estimate.coreTimeMs;
|
|
194
|
+
const raw = estimate.rawWaste;
|
|
195
|
+
if (!raw || !MEASURED_CORE_TIME_FIGURE.has(finding.type)) return null;
|
|
196
|
+
return { low: raw.value, high: raw.value };
|
|
197
|
+
}
|
package/vendor-core/ingest.js
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
|
|
15
15
|
|
|
16
16
|
|
|
17
|
+
|
|
17
18
|
|
|
18
19
|
|
|
19
20
|
|
|
@@ -39,9 +40,10 @@ export function routeMessage(
|
|
|
39
40
|
case 'runAggregates': handlers.onRunAggregates?.(data.data); break;
|
|
40
41
|
case 'stageExecutorMetrics': handlers.onStageExecutorMetrics?.(data.data); break;
|
|
41
42
|
case 'stageSpeculationWaste': handlers.onStageSpeculationWaste?.(data.data); break;
|
|
43
|
+
case 'stageLateAttemptWork': handlers.onStageLateAttemptWork?.(data.data); break;
|
|
42
44
|
case 'done': {
|
|
43
|
-
const { skippedLines } = data;
|
|
44
|
-
handlers.onDone?.({ skippedLines });
|
|
45
|
+
const { skippedLines, unreadableSqlExecutions } = data;
|
|
46
|
+
handlers.onDone?.(unreadableSqlExecutions ? { skippedLines, unreadableSqlExecutions } : { skippedLines });
|
|
45
47
|
break;
|
|
46
48
|
}
|
|
47
49
|
case 'error': handlers.onError?.(data); break;
|
package/vendor-core/list-runs.js
CHANGED
|
@@ -5,6 +5,7 @@ import { reassembleRollingEntries } from './parser-worker.js';
|
|
|
5
5
|
import { peekLogHeader } from './log-header-peek.js';
|
|
6
6
|
import { mcpError } from './mcp-error.js';
|
|
7
7
|
import { normalizeBaseUrl } from './shs-request.js';
|
|
8
|
+
import { isConnectionFailure } from './proxy.js';
|
|
8
9
|
import { DEFAULT_IDLE_TIMEOUT_MS, DEFAULT_MAX_ARCHIVE_BYTES } from './shs-load.js';
|
|
9
10
|
|
|
10
11
|
|
|
@@ -205,10 +206,12 @@ export async function listRunsShs(
|
|
|
205
206
|
let res ;
|
|
206
207
|
try {
|
|
207
208
|
// An unresponsive SHS would otherwise hang the tool call forever. A timed-out signal rejects
|
|
208
|
-
// the fetch with
|
|
209
|
+
// the fetch with a TimeoutError, which this same catch turns into upstream-unreachable, the
|
|
210
|
+
// code a refused connection gets too.
|
|
209
211
|
res = await fetchImpl(url.toString(), { signal: AbortSignal.timeout(DEFAULT_IDLE_TIMEOUT_MS) });
|
|
210
212
|
} catch (e) {
|
|
211
|
-
|
|
213
|
+
const code = isConnectionFailure(e) ? 'upstream-unreachable' : 'access-or-upstream-failure';
|
|
214
|
+
throw mcpError(code, `Could not reach ${normalized}: ${e instanceof Error ? e.message : String(e)}`);
|
|
212
215
|
}
|
|
213
216
|
if (!res.ok) {
|
|
214
217
|
throw mcpError('access-or-upstream-failure', `SHS applications list request failed with status ${res.status}.`);
|
package/vendor-core/mcp-tools.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
|
|
4
4
|
|
|
5
|
+
|
|
5
6
|
|
|
6
7
|
|
|
7
8
|
|
|
@@ -9,6 +10,15 @@
|
|
|
9
10
|
|
|
10
11
|
|
|
11
12
|
|
|
13
|
+
// The done message's counts of what the parse could not read. On the model, not only passed to onDone,
|
|
14
|
+
// so every consumer of the model (dashboard export, CLI, MCP) reports them the same way.
|
|
15
|
+
function recordParseGaps(appModel , data ) {
|
|
16
|
+
const done = data ;
|
|
17
|
+
appModel.skippedLines = done?.skippedLines ?? 0;
|
|
18
|
+
if (done?.unreadableSqlExecutions) appModel.unreadableSqlExecutions = done.unreadableSqlExecutions;
|
|
19
|
+
else delete appModel.unreadableSqlExecutions;
|
|
20
|
+
}
|
|
21
|
+
|
|
12
22
|
// Worker-message -> appModel assembly. Shared by the file-load and SHS-URL-load paths. Pure model
|
|
13
23
|
// mutation: analysis/render/persist stay in the caller via the onDone/onProgress/onError hooks.
|
|
14
24
|
export function createModelCallbacks(
|
|
@@ -75,6 +85,18 @@ export function createModelCallbacks(
|
|
|
75
85
|
}
|
|
76
86
|
}
|
|
77
87
|
},
|
|
78
|
-
onDone
|
|
88
|
+
onDone(data ) {
|
|
89
|
+
recordParseGaps(appModel, data);
|
|
90
|
+
onDone?.(data);
|
|
91
|
+
},
|
|
92
|
+
// Patch the late work of failed attempts, whose TaskEnds arrive after StageCompleted.
|
|
93
|
+
// `data` is Map<stageId, StageAttemptTotals>.
|
|
94
|
+
onStageLateAttemptWork(data ) {
|
|
95
|
+
for (const [stageId, work] of data ) {
|
|
96
|
+
const stage = appModel.stages.get(stageId);
|
|
97
|
+
if (stage) stage.lateAttemptWork = work;
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
onError,
|
|
79
101
|
};
|
|
80
102
|
}
|
|
@@ -45,7 +45,7 @@ const NOT_AN_EVENT_LOG = 'Not a Spark event log: no application-start event foun
|
|
|
45
45
|
// Minimal shape streamFile/runParse/runParseFiles read off `file` (name, size,
|
|
46
46
|
// slice(start,end).arrayBuffer()): narrower than the full DOM `File`. A real
|
|
47
47
|
// `File` (the browser Worker path) satisfies it structurally, but so does the
|
|
48
|
-
// plain object src/cli/collect-run.ts's nodeFileFromPath builds for Node, which
|
|
48
|
+
// plain object packages/core/src/cli/collect-run.ts's nodeFileFromPath builds for Node, which
|
|
49
49
|
// has no DOM `File` constructor.
|
|
50
50
|
|
|
51
51
|
|
package/vendor-core/proxy.js
CHANGED
|
@@ -10,7 +10,9 @@ function sendSafeError(res, status, code) {
|
|
|
10
10
|
res.end(body);
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
// Also read by list-runs.ts, so a History Server listing that can't be reached reports the
|
|
14
|
+
// same upstream-unreachable code as a run fetch.
|
|
15
|
+
export function isConnectionFailure(error) {
|
|
14
16
|
if (error?.name === 'AbortError' || error?.name === 'TimeoutError') return true;
|
|
15
17
|
const code = error?.code ?? error?.cause?.code;
|
|
16
18
|
if (['ECONNREFUSED', 'ECONNRESET', 'EHOSTUNREACH', 'ENETUNREACH', 'ENOTFOUND', 'ETIMEDOUT'].includes(code)) return true;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { planNodesOfStage } from './stage-plan-nodes.js';
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
// Plan operators that hand rows to a Python worker process: the row-at-a-time and Arrow Python UDF
|
|
5
|
+
// evaluators, the pandas/Arrow grouped and map operators, and the PythonRDD scan of an RDD
|
|
6
|
+
// pipeline. A suffixed name is a variant of the same operator (BatchEvalPythonUDTF,
|
|
7
|
+
// FlatMapGroupsInPandasWithState). Spark 4.1 renamed AggregateInPandas and WindowInPandas to
|
|
8
|
+
// ArrowAggregatePython and ArrowWindowPython. Spark prefixes a whole-stage-codegen child's name
|
|
9
|
+
// with "*(n) " in some plan strings.
|
|
10
|
+
const PYTHON_PLAN_NODE = /^(?:\*\(\d+\)\s*)?(?:PythonRDD|BatchEvalPython|ArrowEvalPython|ArrowAggregatePython|ArrowWindowPython|\w+InPandas|\w+InArrow)\w*\b/;
|
|
11
|
+
|
|
12
|
+
// An RDD lambda or map function has no SQL plan to match, and a stage whose plan could not be
|
|
13
|
+
// matched is left with nothing but its name and call site.
|
|
14
|
+
const PYTHON_STAGE_NAME = /PythonRDD/;
|
|
15
|
+
const PYTHON_STAGE_DETAILS = /org\.apache\.spark\.api\.python\./;
|
|
16
|
+
|
|
17
|
+
/** True when the stage ran Python code in a worker process: the union of a Python operator among
|
|
18
|
+
* the plan nodes attributed to it (catches Python UDFs inside SQL) and the stage's own name or
|
|
19
|
+
* call site naming PythonRDD / org.apache.spark.api.python (catches RDD lambdas, which have no
|
|
20
|
+
* plan, and stages that cannot be matched to one). The executor CPU time of such a stage misses
|
|
21
|
+
* the worker process's CPU. */
|
|
22
|
+
export function isPythonStage(stage , sql ) {
|
|
23
|
+
if (PYTHON_STAGE_NAME.test(stage.name ?? '') || PYTHON_STAGE_DETAILS.test(stage.details ?? '')) return true;
|
|
24
|
+
return planNodesOfStage(stage, sql).some((node) => PYTHON_PLAN_NODE.test(node.name ?? ''));
|
|
25
|
+
}
|