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.
Files changed (64) hide show
  1. package/package.json +1 -1
  2. package/vendor-core/allocation.js +106 -0
  3. package/vendor-core/analyzer.js +13 -13
  4. package/vendor-core/cli/budgets.js +23 -9
  5. package/vendor-core/cli/collect-run.js +11 -4
  6. package/vendor-core/cli/regression-budgets.js +83 -0
  7. package/vendor-core/comparison-verdict.js +22 -22
  8. package/vendor-core/core-source-hash.txt +1 -1
  9. package/vendor-core/detectors.js +202 -68
  10. package/vendor-core/docs-content/detection/cache.md +3 -2
  11. package/vendor-core/docs-content/detection/cfg.md +9 -8
  12. package/vendor-core/docs-content/detection/chrn.md +1 -2
  13. package/vendor-core/docs-content/detection/cold.md +4 -2
  14. package/vendor-core/docs-content/detection/fail.md +3 -2
  15. package/vendor-core/docs-content/detection/gc.md +3 -2
  16. package/vendor-core/docs-content/detection/host.md +2 -1
  17. package/vendor-core/docs-content/detection/local.md +1 -1
  18. package/vendor-core/docs-content/detection/mem.md +5 -2
  19. package/vendor-core/docs-content/detection/plan.md +2 -1
  20. package/vendor-core/docs-content/detection/sfail.md +2 -1
  21. package/vendor-core/docs-content/detection/shape.md +5 -4
  22. package/vendor-core/docs-content/detection/skew.md +3 -1
  23. package/vendor-core/docs-content/detection/slow.md +2 -2
  24. package/vendor-core/docs-content/detection/spec.md +2 -3
  25. package/vendor-core/docs-content/detection/spill.md +1 -1
  26. package/vendor-core/docs-site-config.js +1 -1
  27. package/vendor-core/effective-conf.js +107 -0
  28. package/vendor-core/efficiency-model.js +8 -6
  29. package/vendor-core/event-handlers.js +160 -47
  30. package/vendor-core/event-schemas.js +2 -0
  31. package/vendor-core/evidence-report.js +18 -10
  32. package/vendor-core/finding-generic-recommendation.js +20 -1
  33. package/vendor-core/finding-names.js +7 -0
  34. package/vendor-core/finding-presentation.js +61 -26
  35. package/vendor-core/finding-tag-help.js +1 -1
  36. package/vendor-core/finding-types.js +12 -0
  37. package/vendor-core/format-utils.js +4 -3
  38. package/vendor-core/impact-estimator.js +20 -2
  39. package/vendor-core/impact-format.js +14 -13
  40. package/vendor-core/impact-model.js +27 -5
  41. package/vendor-core/ingest.js +4 -2
  42. package/vendor-core/list-runs.js +5 -2
  43. package/vendor-core/mcp-tools.js +1 -1
  44. package/vendor-core/model-assembler.js +23 -1
  45. package/vendor-core/parser-worker.js +1 -1
  46. package/vendor-core/proxy.js +3 -1
  47. package/vendor-core/python-stage.js +25 -0
  48. package/vendor-core/recommendation-rollup.js +16 -9
  49. package/vendor-core/redact.js +51 -10
  50. package/vendor-core/remediation.js +20 -0
  51. package/vendor-core/run-comparison.js +43 -24
  52. package/vendor-core/run-interpretation.js +2 -1
  53. package/vendor-core/run-metrics.js +198 -0
  54. package/vendor-core/run-totals.js +24 -0
  55. package/vendor-core/run-verdict.js +3 -4
  56. package/vendor-core/scorecard-estimates.js +1 -0
  57. package/vendor-core/session-snapshot.js +7 -0
  58. package/vendor-core/shs-schemas.js +2 -2
  59. package/vendor-core/spark-memory.js +17 -0
  60. package/vendor-core/stage-plan-nodes.js +18 -0
  61. package/vendor-core/stage-quantiles.js +4 -0
  62. package/vendor-core/types.js +49 -1
  63. package/vendor-core/wasted-core-hours.js +10 -7
  64. 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/job-completion event',
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: () => 'task duration skew above the configured ratio',
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: () => '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.',
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: () => 'low parallelism, data explosion, or task-count skew relative to core count',
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: () => 'median task duration below the configured floor',
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: () => 'shuffle read above the configured minimum byte threshold',
130
+ thresholdSummary: (t) => `stage shuffle read above ${t.minBytes / 1048576} MiB`,
107
131
  actionLabel: () => 'Reduce shuffle size',
108
- genericRecommendation: () => 'Consider increasing spark.sql.shuffle.partitions or adding a broadcast join to shrink the shuffle.',
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: () => 'partition byte size outside the configured target range',
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 'Raise spark.sql.shuffle.partitions so each partition is smaller.';
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: () => 'JVM GC time share above the configured ratio',
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: () => 'task failures above the configured rate',
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: () => 'retried task attempts consuming executor time',
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 running ${t.ratioWarn}x+ slower than its peers by mean task duration (per-executor byte/time dimensions use a separate, narrower ratio ladder starting at ${t.ratioTiers[0]}x; only those can reach critical on ratio alone)`,
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
- return '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. Enable spark.speculation to relaunch a lagging task automatically.';
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: () => 'speculative task attempts that completed after the original',
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: () => 'executor startup time above the configured floor',
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 'Reduce cluster size or enable dynamic allocation.';
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: () => 'core occupancy below the configured floor across the run',
278
+ thresholdSummary: (t) => `average executor utilization below ${shareLabel(t.minUtil)}`,
248
279
  actionLabel: () => 'Reduce cluster size',
249
- genericRecommendation: () => 'Consider reducing cluster size or enabling dynamic allocation.',
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 spark.locality.wait settings and executor/data colocation.',
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: () => 'job failure rate above the configured threshold',
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: () => 'executor add/remove churn above the configured rate',
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: () => 'output files below the configured target size',
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: () => 'a broadcast join above the configured size ceiling',
357
+ thresholdSummary: (t) => `a broadcast over ${t.overBroadcastBytes / 1073741824} GiB`,
325
358
  actionLabel: () => 'Fix oversized broadcast',
326
- genericRecommendation: () => 'Check for a misapplied broadcast hint or a misconfigured spark.sql.autoBroadcastJoinThreshold.',
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, verdict "What's happening") and the
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
- taskStageSkew: 'ratio', hostMeanRatio: 'ratio', execMaxMedianRatio: 'ratio',
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) f.impactEstimate = 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
- export function rawWasteMeaning(unit ) {
23
- switch (unit) {
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?.unit);
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 .unit) };
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 ? `No run-time claim, ${method}. ${raw} was wasted, but it may not shorten the run.` : null;
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 `${highText}, ${method}. The stage ran effectively alone, so this is close to a point estimate.${rawNote}`;
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 range = formatWallClockRange(wallClock.low, wallClock.high);
110
- const spread = lowText === highText ? 'its floor and optimistic high agree' : `${lowText} is the floor, ${highText} assumes the fix fully lands`;
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 PythonRDD, whose worker-process CPU executorCpuTime (the JVM task thread's)
108
- // never counts (such stages read 0.1% on the same logs while computing).
109
- export function tasksMostlyIdle(stage ) {
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 (/PythonRDD/.test(stage.name ?? '') || /org\.apache\.spark\.api\.python\./.test(stage.details ?? '')) return false;
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
+ }
@@ -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;
@@ -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 an AbortError, which this same catch turns into access-or-upstream-failure.
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
- throw mcpError('access-or-upstream-failure', `Could not reach ${normalized}: ${e instanceof Error ? e.message : String(e)}`);
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}.`);
@@ -52,7 +52,7 @@ import { typeTag } from './format-utils.js';
52
52
 
53
53
 
54
54
 
55
-
55
+
56
56
 
57
57
 
58
58
 
@@ -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, onError,
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
 
@@ -10,7 +10,9 @@ function sendSafeError(res, status, code) {
10
10
  res.end(body);
11
11
  }
12
12
 
13
- function isConnectionFailure(error) {
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
+ }