sparkforensics-mcp 0.3.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 (92) hide show
  1. package/bin/sparkforensics-mcp.mjs +41 -10
  2. package/package.json +1 -1
  3. package/vendor-core/allocation.js +106 -0
  4. package/vendor-core/analyzer.js +168 -60
  5. package/vendor-core/check-coverage.js +88 -0
  6. package/vendor-core/cli/budgets.js +54 -27
  7. package/vendor-core/cli/collect-run.js +84 -32
  8. package/vendor-core/cli/regression-budgets.js +83 -0
  9. package/vendor-core/cli/threshold-config.js +28 -0
  10. package/vendor-core/comparison-verdict.js +177 -0
  11. package/vendor-core/core-source-hash.txt +1 -0
  12. package/vendor-core/core-usage-locality.js +56 -2
  13. package/vendor-core/detector-docs.js +58 -0
  14. package/vendor-core/detectors.js +1094 -500
  15. package/vendor-core/docs-config.js +0 -36
  16. package/vendor-core/docs-content/chapters/nav-index.json +31 -0
  17. package/vendor-core/docs-content/detection/cache.md +3 -2
  18. package/vendor-core/docs-content/detection/cfg.md +9 -8
  19. package/vendor-core/docs-content/detection/chrn.md +1 -2
  20. package/vendor-core/docs-content/detection/cold.md +4 -2
  21. package/vendor-core/docs-content/detection/cstor.md +9 -0
  22. package/vendor-core/docs-content/detection/fail.md +3 -2
  23. package/vendor-core/docs-content/detection/gc.md +3 -2
  24. package/vendor-core/docs-content/detection/host.md +2 -1
  25. package/vendor-core/docs-content/detection/local.md +1 -1
  26. package/vendor-core/docs-content/detection/mem.md +5 -2
  27. package/vendor-core/docs-content/detection/plan.md +2 -1
  28. package/vendor-core/docs-content/detection/sfail.md +2 -1
  29. package/vendor-core/docs-content/detection/shape.md +5 -4
  30. package/vendor-core/docs-content/detection/skew.md +3 -1
  31. package/vendor-core/docs-content/detection/slow.md +2 -2
  32. package/vendor-core/docs-content/detection/spec.md +2 -3
  33. package/vendor-core/docs-content/detection/spill.md +1 -1
  34. package/vendor-core/docs-site-config.js +3 -0
  35. package/vendor-core/effective-conf.js +107 -0
  36. package/vendor-core/efficiency-model.js +8 -6
  37. package/vendor-core/event-handlers.js +321 -44
  38. package/vendor-core/event-schemas.js +23 -0
  39. package/vendor-core/evidence-report.js +432 -115
  40. package/vendor-core/export-data.js +79 -6
  41. package/vendor-core/finding-action-label.js +9 -88
  42. package/vendor-core/finding-filter-predicate.js +9 -0
  43. package/vendor-core/finding-generic-recommendation.js +26 -105
  44. package/vendor-core/finding-names.js +28 -45
  45. package/vendor-core/finding-presentation.js +368 -0
  46. package/vendor-core/finding-tag-help.js +110 -0
  47. package/vendor-core/finding-types.js +373 -0
  48. package/vendor-core/findings-of-type.js +11 -0
  49. package/vendor-core/format-utils.js +96 -30
  50. package/vendor-core/html-export.js +51 -0
  51. package/vendor-core/impact-band.js +21 -8
  52. package/vendor-core/impact-estimator.js +25 -520
  53. package/vendor-core/impact-format.js +115 -0
  54. package/vendor-core/impact-model.js +197 -0
  55. package/vendor-core/ingest.js +6 -2
  56. package/vendor-core/intervals.js +13 -0
  57. package/vendor-core/list-runs.js +7 -5
  58. package/vendor-core/load-vendored.js +70 -5
  59. package/vendor-core/mcp-server-factory.js +14 -10
  60. package/vendor-core/mcp-tools.js +105 -45
  61. package/vendor-core/model-assembler.js +35 -1
  62. package/vendor-core/occupancy.js +1 -1
  63. package/vendor-core/parser-worker.js +2 -2
  64. package/vendor-core/plan-graph-model.js +3 -2
  65. package/vendor-core/plan-node-detail.js +1 -1
  66. package/vendor-core/proxy.js +3 -1
  67. package/vendor-core/python-stage.js +25 -0
  68. package/vendor-core/recommendation-rollup.js +70 -3
  69. package/vendor-core/redact.js +96 -37
  70. package/vendor-core/remediation.js +20 -0
  71. package/vendor-core/run-comparison.js +73 -29
  72. package/vendor-core/run-interpretation.js +291 -0
  73. package/vendor-core/run-metrics.js +198 -0
  74. package/vendor-core/run-outcome.js +74 -0
  75. package/vendor-core/run-payload.js +17 -0
  76. package/vendor-core/run-shape.js +40 -0
  77. package/vendor-core/run-totals.js +24 -0
  78. package/vendor-core/run-verdict.js +352 -0
  79. package/vendor-core/scaling-sim.js +4 -5
  80. package/vendor-core/scorecard-estimates.js +63 -0
  81. package/vendor-core/session-snapshot.js +7 -0
  82. package/vendor-core/shs-schemas.js +2 -2
  83. package/vendor-core/spark-memory.js +17 -0
  84. package/vendor-core/sql-stages.js +11 -0
  85. package/vendor-core/stage-plan-nodes.js +18 -0
  86. package/vendor-core/stage-quantiles.js +6 -0
  87. package/vendor-core/threshold-overrides.js +160 -0
  88. package/vendor-core/threshold-summary.js +11 -33
  89. package/vendor-core/types.js +54 -42
  90. package/vendor-core/wall-clock.js +1 -12
  91. package/vendor-core/wasted-core-hours.js +12 -9
  92. package/vendor-core/write-targets.js +312 -0
@@ -0,0 +1,160 @@
1
+ // User threshold overrides: validation of a parsed config file, and which of a detector's
2
+ // thresholds an override actually moved off its specification default. Only the CLI and the MCP
3
+ // server accept overrides; the dashboard always runs the defaults. Reading the file is
4
+ // cli/threshold-config.ts's job, so this module stays free of Node APIs.
5
+ import {
6
+ DETECTORS, ENTRY_BY_TYPE, detectorCatalog,
7
+ } from './detectors.js';
8
+
9
+
10
+ const entries = DETECTORS;
11
+
12
+ function isPlainObject(value ) {
13
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
14
+ }
15
+
16
+ function isNonNegativeNumber(value ) {
17
+ return typeof value === 'number' && Number.isFinite(value) && value >= 0;
18
+ }
19
+
20
+ // Entries a user may tune: not config-scope (those audits compare against Spark's own defaults,
21
+ // such as its memoryOverhead floor), and with at least one threshold.
22
+ function tunableEntry(type ) {
23
+ const entry = entries.find((d) => d.type === type);
24
+ if (!entry) {
25
+ const tunable = entries.filter((d) => d.scope !== 'config' && Object.keys(d.thresholds).length > 0).map((d) => d.type);
26
+ throw new Error(`unknown detector "${type}" (tunable detectors: ${tunable.join(', ')})`);
27
+ }
28
+ if (entry.scope === 'config') throw new Error(`"${type}" is not tunable: its checks compare against Spark's own defaults`);
29
+ if (Object.keys(entry.thresholds).length === 0) throw new Error(`"${type}" has no thresholds to tune`);
30
+ return entry;
31
+ }
32
+
33
+ function checkValue(type , name , fallback , value ) {
34
+ if (!Array.isArray(fallback)) {
35
+ if (!isNonNegativeNumber(value)) throw new Error(`"${type}.${name}" must be a non-negative number`);
36
+ return value;
37
+ }
38
+ // Tier tables are indexed by position and read as ascending bands.
39
+ const ascending = Array.isArray(value) && value.length === fallback.length
40
+ && value.every(isNonNegativeNumber) && value.every((v, i) => i === 0 || v >= value[i - 1]);
41
+ if (!ascending) throw new Error(`"${type}.${name}" must be an ascending list of ${fallback.length} non-negative numbers`);
42
+ return Object.freeze([...value]);
43
+ }
44
+
45
+ /**
46
+ * Validates a parsed thresholds config: `{ "<detector type>": { "<threshold>": value } }`, with each
47
+ * value shaped like that threshold's default (see a report's `detectors` catalog for the names,
48
+ * units and defaults). Throws an Error naming the first problem; never drops a bad entry silently.
49
+ */
50
+ export function parseThresholdOverrides(raw ) {
51
+ if (!isPlainObject(raw)) throw new Error('expected a JSON object keyed by detector type');
52
+ const result = {};
53
+ for (const [type, perDetector] of Object.entries(raw)) {
54
+ const entry = tunableEntry(type);
55
+ if (!isPlainObject(perDetector)) throw new Error(`"${type}" must be an object of threshold values`);
56
+ const values = {};
57
+ for (const [name, value] of Object.entries(perDetector)) {
58
+ // Own keys only: an inherited name such as `constructor` or `toString` is no threshold.
59
+ if (!Object.hasOwn(entry.thresholds, name)) {
60
+ throw new Error(`unknown threshold "${type}.${name}" (${type} thresholds: ${Object.keys(entry.thresholds).join(', ')})`);
61
+ }
62
+ values[name] = checkValue(type, name, entry.thresholds[name], value);
63
+ }
64
+ result[type] = Object.freeze(values);
65
+ }
66
+ return Object.freeze(result) ;
67
+ }
68
+
69
+ /** The overrides for one entry, as the untyped map withThresholds() takes. */
70
+ export function overridesFor(entry , overrides ) {
71
+ return (overrides )?.[entry.type];
72
+ }
73
+
74
+ function sameValue(a , b ) {
75
+ if (Array.isArray(a) && Array.isArray(b)) return a.length === b.length && a.every((v, i) => v === b[i]);
76
+ return a === b;
77
+ }
78
+
79
+ /** The entry's thresholds that `overrides` moves off the default, or null when none does (no
80
+ * override, or one equal to the default: that run is the specification's). */
81
+ export function tunedThresholdsOf(entry , overrides ) {
82
+ const tuned = {};
83
+ for (const [name, value] of Object.entries(overridesFor(entry, overrides) ?? {})) {
84
+ if (!Object.hasOwn(entry.thresholds, name)) continue;
85
+ const fallback = entry.thresholds[name];
86
+ if (!sameValue(value, fallback)) tuned[name] = { value, default: fallback };
87
+ }
88
+ return Object.keys(tuned).length > 0 ? tuned : null;
89
+ }
90
+
91
+ /** The tuned thresholds an entry's findings carry: its own, plus its `suppressedBy` entry's
92
+ * (named `<suppressor>.<threshold>`), since tuning the suppressor changes which of them survive. */
93
+ export function findingTunedThresholds(entry , overrides ) {
94
+ const own = tunedThresholdsOf(entry, overrides);
95
+ const suppressor = entry.suppressedBy ? entries.find((d) => d.type === entry.suppressedBy) : undefined;
96
+ const bySuppressor = suppressor ? tunedThresholdsOf(suppressor, overrides) : null;
97
+ if (!suppressor || !bySuppressor) return own;
98
+ const prefixed = Object.fromEntries(Object.entries(bySuppressor).map(([name, t]) => [`${suppressor.type}.${name}`, t]));
99
+ return { ...own, ...prefixed };
100
+ }
101
+
102
+ /** findingTunedThresholds() for the first entry emitting finding type `type`, the entry whose
103
+ * thresholds its clean-check summary reads. */
104
+ export function tunedThresholdsForType(type , overrides ) {
105
+ const entry = ENTRY_BY_TYPE.get(type);
106
+ return entry ? findingTunedThresholds(entry, overrides) : null;
107
+ }
108
+
109
+ /** Every tuned detector's overridden thresholds, keyed by entry type, or null when none is tuned. */
110
+ export function tunedDetectors(overrides ) {
111
+ const byType = {};
112
+ for (const entry of entries) {
113
+ const tuned = tunedThresholdsOf(entry, overrides);
114
+ if (tuned) byType[entry.type] = tuned;
115
+ }
116
+ return Object.keys(byType).length > 0 ? byType : null;
117
+ }
118
+
119
+ /** The thresholds an entry runs with under `overrides`: its own, with any override merged in. */
120
+ export function effectiveThresholds(entry , overrides ) {
121
+ const own = overridesFor(entry, overrides);
122
+ return own ? { ...entry.thresholds, ...own } : entry.thresholds;
123
+ }
124
+
125
+ /** detectorCatalog() as a run under `overrides` used it: each row's effective thresholds, plus
126
+ * `tunedThresholds` on a row an override moved off its defaults. */
127
+ export function tunedDetectorCatalog(overrides ) {
128
+ return detectorCatalog().map((row, i) => {
129
+ const entry = entries[i];
130
+ const tuned = tunedThresholdsOf(entry, overrides);
131
+ return tuned ? { ...row, thresholds: effectiveThresholds(entry, overrides), tunedThresholds: tuned } : row;
132
+ });
133
+ }
134
+
135
+ function formatThresholdValue(value ) {
136
+ return Array.isArray(value) ? `[${value.join(', ')}]` : String(value);
137
+ }
138
+
139
+ /** "ratioWarn 5 (default 3), minTasksForP95 40 (default 20)". */
140
+ export function describeTunedThresholds(tuned ) {
141
+ return Object.entries(tuned)
142
+ .map(([name, { value, default: fallback }]) => `${name} ${formatThresholdValue(value)} (default ${formatThresholdValue(fallback)})`)
143
+ .join(', ');
144
+ }
145
+
146
+ /** A tuned run's report line after its label: every tuned detector's thresholds, then why its
147
+ * findings' estimates are uncalibrated. `byType` is tunedDetectors()'s result. */
148
+ export function tunedRunNote(byType ) {
149
+ const tuned = Object.entries(byType).map(([type, t]) => `${type} ${describeTunedThresholds(t)}`).join('; ');
150
+ return `${tuned}. Findings from these detectors are marked, and their impact estimates are uncalibrated: the estimates are calibrated against the default thresholds.`;
151
+ }
152
+
153
+ /** The caveat a tuned finding carries in `validationRequired`: which thresholds produced it and,
154
+ * when it has an estimate figure (`hasEstimate`), that the figure was never calibrated. */
155
+ export function tunedThresholdsNote(tuned , hasEstimate ) {
156
+ const label = `Produced with tuned thresholds: ${describeTunedThresholds(tuned)}.`;
157
+ return hasEstimate
158
+ ? `${label} Impact estimates are calibrated against the default thresholds, so this finding's estimate is unvalidated.`
159
+ : label;
160
+ }
@@ -1,35 +1,13 @@
1
- const THRESHOLD_SUMMARIES = {
2
- spill: 'single-task disk spill above 1 GiB',
3
- shuffle: 'shuffle read above the configured minimum byte threshold',
4
- skew: 'task duration skew above the configured ratio',
5
- gc: 'JVM GC time share above the configured ratio',
6
- slowHost: 'a host running 2x+ slower than its peers by mean task duration (per-executor byte/time dimensions use a separate, narrower ratio ladder starting at 1.33x; only those can reach critical on ratio alone)',
7
- stageSlowness: 'a stage running far longer than its peers, not attributable to a single slow host',
8
- straggler: 'one or more tasks finishing far after the rest of their stage',
9
- speculationWaste: 'speculative task attempts that completed after the original',
10
- tinyTask: 'median task duration below the configured floor',
11
- partitionSizing: 'partition byte size outside the configured target range',
12
- stageShape: 'low parallelism, data explosion, or task-count skew relative to core count',
13
- stageFailed: 'a stage that failed outright',
14
- failures: 'task failures above the configured rate',
15
- retryWaste: 'retried task attempts consuming executor time',
16
- coldStart: 'executor startup time above the configured floor',
17
- incompleteRun: 'an event log missing its terminal ApplicationEnd/job-completion event',
18
- utilization: 'core occupancy below the configured floor across the run',
19
- memoryUtilization: 'executor heap usage outside the configured band',
20
- cacheUtilization: 'cached partitions evicted or spilled to disk',
21
- coreLocality: 'task placement missing data-local core assignment',
22
- cachingOpportunity: 'a dataset re-read from source multiple times with no cache/persist',
23
- jobFailureRate: 'job failure rate above the configured threshold',
24
- autoscalingChurn: 'executor add/remove churn above the configured rate',
25
- configAudit: 'a Spark conf value outside the recommended range',
26
- duplicatePlanSubtree: 'the same physical plan subtree executed more than once',
27
- smallFiles: 'output files below the configured target size',
28
- overBroadcast: 'a broadcast join above the configured size ceiling',
29
- underBroadcast: 'a join below the configured size floor that skipped broadcast',
30
- broadcastSizing: 'a broadcast join outside the configured size range in either direction',
31
- };
1
+ import { ENTRY_BY_TYPE, } from './detectors.js';
2
+ import { presentationOf } from './finding-presentation.js';
3
+ import { effectiveThresholds } from './threshold-overrides.js';
32
4
 
33
- export function getThresholdSummary(type ) {
34
- return THRESHOLD_SUMMARIES[type] ?? 'criteria not met';
5
+ /** The clean-check criterion for a finding type, built from the thresholds of the first DETECTORS
6
+ * entry that emits it (configAudit's four entries share one summary), with `overrides` merged in
7
+ * when the run was tuned. */
8
+ export function getThresholdSummary(type , overrides ) {
9
+ const presentation = presentationOf(type);
10
+ const entry = ENTRY_BY_TYPE.get(type);
11
+ if (!presentation || !entry) return 'criteria not met';
12
+ return presentation.thresholdSummary(effectiveThresholds(entry, overrides) );
35
13
  }
@@ -1,3 +1,6 @@
1
+
2
+
3
+
1
4
 
2
5
 
3
6
 
@@ -54,6 +57,24 @@
54
57
 
55
58
 
56
59
 
60
+
61
+
62
+
63
+
64
+
65
+
66
+
67
+
68
+
69
+
70
+
71
+
72
+
73
+
74
+
75
+
76
+
77
+
57
78
 
58
79
 
59
80
 
@@ -67,6 +88,20 @@
67
88
 
68
89
 
69
90
 
91
+
92
+
93
+
94
+
95
+
96
+
97
+
98
+
99
+
100
+
101
+
102
+
103
+
104
+
70
105
 
71
106
 
72
107
 
@@ -260,6 +295,10 @@
260
295
 
261
296
 
262
297
 
298
+
299
+
300
+
301
+
263
302
 
264
303
 
265
304
 
@@ -269,6 +308,9 @@
269
308
 
270
309
 
271
310
 
311
+
312
+
313
+
272
314
 
273
315
 
274
316
 
@@ -285,50 +327,20 @@
285
327
 
286
328
 
287
329
 
330
+
331
+
332
+
333
+
334
+
335
+
336
+
337
+
338
+
288
339
 
289
340
 
290
-
291
-
292
-
293
-
294
-
295
-
296
-
297
-
298
-
299
-
300
-
301
-
302
-
303
-
304
-
305
-
306
-
307
-
308
-
309
-
310
-
311
-
312
-
313
-
314
-
315
-
316
-
317
-
318
-
319
-
320
-
321
-
322
-
323
-
324
-
325
-
326
-
327
-
328
-
329
-
330
-
331
-
341
+ // `Finding` is a union discriminated on `type`, one member per emitted finding type: see
342
+ // finding-types.ts for each detector's shape and which of its fields are public evidence.
343
+
332
344
 
333
345
 
334
346
 
@@ -1,15 +1,4 @@
1
- export function mergeIntervals(intervals ) {
2
- if (intervals.length === 0) return [];
3
- const sorted = [...intervals].sort((a, b) => a[0] - b[0]);
4
- const out = [[sorted[0][0], sorted[0][1]]];
5
- for (let i = 1; i < sorted.length; i++) {
6
- const last = out[out.length - 1];
7
- const cur = sorted[i];
8
- if (cur[0] <= last[1]) last[1] = Math.max(last[1], cur[1]);
9
- else out.push([cur[0], cur[1]]);
10
- }
11
- return out;
12
- }
1
+ import { mergeIntervals } from './intervals.js';
13
2
 
14
3
  export function computeWallClock(app , stages ) {
15
4
  const start = app?.startTime ?? 0;
@@ -2,12 +2,12 @@
2
2
  // the memoryUtilization detector's idle-cores math uses: capacity core-time vs.
3
3
  // core-time that actually ran tasks. Feeds a report widget only, no detector.
4
4
 
5
- import { computeTotalCores } from './core-count.js';
5
+ import { computePeakConcurrentCores } from './core-count.js';
6
+ import { MS_PER_CORE_HOUR } from './format-utils.js';
6
7
 
7
- export const MS_PER_CORE_HOUR = 3.6e6;
8
8
  const TOP_N = 5;
9
9
 
10
-
10
+
11
11
 
12
12
 
13
13
 
@@ -30,21 +30,24 @@ const EMPTY = {
30
30
 
31
31
  // `app`/`runAggregates` are nullable because real callers pass null (app is
32
32
  // SparkAppInfo | null before parsing completes), which the guard below already
33
- // tolerates. `resources` is on app's shape so the computeTotalCores pass-through
34
- // type-checks; real app objects always carry it.
33
+ // tolerates. `resources` is on app's shape so the computePeakConcurrentCores
34
+ // pass-through type-checks; real app objects always carry it. An omitted
35
+ // `executorsRemoved` means no executor left, where peak cores equal the sum.
35
36
  export function computeWastedCoreHours(
36
37
  app ,
37
- executorsAdded = [],
38
+ executorsAdded = [],
38
39
  runAggregates ,
40
+ executorsRemoved = [],
39
41
  ) {
40
42
  // Nullish (not falsy) guard on times: a literal startTime:0 is valid.
41
43
  if (!runAggregates || app?.startTime == null || app?.endTime == null) return EMPTY;
42
44
  const appDurationMs = app.endTime - app.startTime;
43
45
  if (appDurationMs <= 0) return EMPTY;
44
46
 
45
- // Total cores: prefer real Executor-Added Total Cores, else peakExecutors ×
46
- // configured cores (same fallback as the memoryUtilization detector).
47
- const totalCores = computeTotalCores(app, executorsAdded);
47
+ // Total cores: peak concurrent Executor-Added Total Cores, else peak executors ×
48
+ // configured cores. Same capacity as the memoryUtilization detector, so an
49
+ // executor replaced mid-run is not counted twice.
50
+ const totalCores = computePeakConcurrentCores(app, executorsAdded, executorsRemoved);
48
51
  if (totalCores <= 0) return EMPTY;
49
52
 
50
53
  const totalCoreHours = (totalCores * appDurationMs) / MS_PER_CORE_HOUR;