sparkforensics-cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/bin/sparkforensics-analyze.mjs +288 -0
  2. package/package.json +29 -0
  3. package/vendor-core/analyzer.js +167 -0
  4. package/vendor-core/assert-never.js +3 -0
  5. package/vendor-core/cli/budgets.js +203 -0
  6. package/vendor-core/cli/collect-run.js +107 -0
  7. package/vendor-core/core-count.js +68 -0
  8. package/vendor-core/core-locality-ratio.js +54 -0
  9. package/vendor-core/core-time-series.js +92 -0
  10. package/vendor-core/core-usage-locality.js +70 -0
  11. package/vendor-core/detectors.js +1989 -0
  12. package/vendor-core/docs-config.js +72 -0
  13. package/vendor-core/docs-site-config.js +23 -0
  14. package/vendor-core/efficiency-model.js +62 -0
  15. package/vendor-core/etl-phases.js +28 -0
  16. package/vendor-core/event-handlers.js +906 -0
  17. package/vendor-core/event-schemas.js +405 -0
  18. package/vendor-core/evidence-availability.js +121 -0
  19. package/vendor-core/evidence-report.js +459 -0
  20. package/vendor-core/finding-action-label.js +97 -0
  21. package/vendor-core/finding-filter-predicate.js +38 -0
  22. package/vendor-core/format-utils.js +167 -0
  23. package/vendor-core/impact-band.js +50 -0
  24. package/vendor-core/impact-estimator.js +428 -0
  25. package/vendor-core/ingest.js +139 -0
  26. package/vendor-core/job-groups.js +30 -0
  27. package/vendor-core/load-vendored.js +24 -0
  28. package/vendor-core/lz4-block.js +135 -0
  29. package/vendor-core/mcp-error.js +3 -0
  30. package/vendor-core/mcp-server-factory.js +115 -0
  31. package/vendor-core/mcp-tools.js +331 -0
  32. package/vendor-core/model-assembler.js +76 -0
  33. package/vendor-core/occupancy.js +202 -0
  34. package/vendor-core/parser-worker.js +249 -0
  35. package/vendor-core/plan-dot.js +25 -0
  36. package/vendor-core/plan-duration-attribution.js +185 -0
  37. package/vendor-core/plan-graph-model.js +171 -0
  38. package/vendor-core/plan-node-detail.js +159 -0
  39. package/vendor-core/plan-summary.js +233 -0
  40. package/vendor-core/plan-tree-walk.js +29 -0
  41. package/vendor-core/proxy.js +157 -0
  42. package/vendor-core/recommendation-rollup.js +197 -0
  43. package/vendor-core/redact.js +175 -0
  44. package/vendor-core/rolling-log-reassembly.js +52 -0
  45. package/vendor-core/run-aggregates.js +44 -0
  46. package/vendor-core/run-comparison.js +458 -0
  47. package/vendor-core/scaling-sim.js +73 -0
  48. package/vendor-core/session-snapshot.js +79 -0
  49. package/vendor-core/shs-fetch.js +196 -0
  50. package/vendor-core/shs-load.js +121 -0
  51. package/vendor-core/shs-request.js +101 -0
  52. package/vendor-core/shs-schemas.js +13 -0
  53. package/vendor-core/snappy-block.js +140 -0
  54. package/vendor-core/stage-quantiles.js +199 -0
  55. package/vendor-core/threshold-summary.js +35 -0
  56. package/vendor-core/types.js +286 -0
  57. package/vendor-core/vendor/fflate.js +2695 -0
  58. package/vendor-core/vendor/fzstd.js +768 -0
  59. package/vendor-core/wall-clock.js +36 -0
  60. package/vendor-core/wasted-core-hours.js +68 -0
@@ -0,0 +1,167 @@
1
+ const TARGET_PARTITION_BYTES = 128 * 1024 * 1024;
2
+ const MAX_RECOMMENDED = 8000;
3
+ const MEANINGFUL_RATIO = 1.5;
4
+
5
+ export const VISIBLE_LIMIT = 6;
6
+ export const IMPACT_BAND_ORDER = { critical: 0, warning: 1, info: 2 };
7
+
8
+ const BOTTLENECK_WIDGET = {
9
+ skew: 'task-skew', slowHost: 'task-skew', straggler: 'task-skew',
10
+ shuffle: 'shuffle-io', spill: 'spill', gc: 'gc-pressure', failures: 'failures',
11
+ coldStart: 'executor-timeline', utilization: 'executor-timeline', speculationWaste: 'executor-timeline',
12
+ };
13
+
14
+ // Cross-widget stage recurrence: how many *distinct board widgets* (not raw
15
+ // catalog entries: skew+straggler on one stage still count as one widget,
16
+ // task-skew) flag a given stage. Computed once from the full catalog each
17
+ // widget already receives, so no new parameter threads through the
18
+ // dashboard-renderer render loop.
19
+ // `stageId` widened to also accept `undefined` (Task 22, src/detectors.ts):
20
+ // sql/config-scope findings never set `stageId` at all, so `Finding.stageId`
21
+ // is now `number | null | undefined`, and this `catalog` param is usually a
22
+ // live `Finding[]`. `stageId == null` below already treats both the same.
23
+ export function stageWidgetFrequency(catalog ) {
24
+ const perStage = new Map();
25
+ for (const b of catalog) {
26
+ if (b.stageId == null) continue;
27
+ const widget = BOTTLENECK_WIDGET[b.type];
28
+ if (!widget) continue;
29
+ if (!perStage.has(b.stageId)) perStage.set(b.stageId, new Set());
30
+ perStage.get(b.stageId).add(widget);
31
+ }
32
+ const freq = new Map();
33
+ for (const [stageId, widgets] of perStage) freq.set(stageId, widgets.size);
34
+ return freq;
35
+ }
36
+
37
+ // Canonical detector type → ALL-CAPS board tag vocabulary (AGENTS.md "Problem
38
+ // flagging"). Single source of truth for every widget that needs to render a
39
+ // catalog entry's tag outside its own dedicated board card, e.g. Bottleneck
40
+ // Alerts, which lists findings across every detector type.
41
+ // Exported (not just used internally by typeTag) so doc-sync checks can
42
+ // enumerate every tag value without hand-duplicating this list elsewhere.
43
+ export const TYPE_TAG_MAP = {
44
+ skew: 'SKEW', shuffle: 'SHFL', spill: 'SPILL', gc: 'GC',
45
+ coldStart: 'COLD', utilization: 'UTIL', memoryUtilization: 'MEM',
46
+ cacheUtilization: 'CSTOR', coreLocality: 'LOCAL', autoscalingChurn: 'CHRN',
47
+ slowHost: 'HOST', failures: 'FAIL', straggler: 'STRAG',
48
+ retryWaste: 'RETRY', tinyTask: 'TINY', stageFailed: 'SFAIL',
49
+ speculationWaste: 'SPEC',
50
+ partitionSizing: 'PART', stageSlowness: 'SLOW', stageShape: 'SHAPE',
51
+ cachingOpportunity: 'CACHE', jobFailureRate: 'JOBS', configAudit: 'CFG',
52
+ duplicatePlanSubtree: 'PLAN', smallFiles: 'PLAN', underBroadcast: 'PLAN', overBroadcast: 'PLAN',
53
+ broadcastSizing: 'PLAN',
54
+ incompleteRun: 'INCMP',
55
+ };
56
+
57
+ export function typeTag(type ) {
58
+ return TYPE_TAG_MAP[type] ?? type.toUpperCase();
59
+ }
60
+
61
+ // Human-readable tooltips for the terse spill-classification badges
62
+ // (skew | vol | ?), surfaced via title= so the vocabulary is decipherable
63
+ // without an external key. Kept domain-agnostic (Spark-internal terms only).
64
+ export const SPILL_CLASS_TITLE = {
65
+ skew: 'Skew spill: a few heavy tasks spill while most do not; rebalance partitioning',
66
+ volume: 'Volume spill: most tasks spill because data exceeds memory; add partitions',
67
+ unclassified: 'Spill cause could not be classified',
68
+ };
69
+
70
+ /**
71
+ * Worst (lowest IMPACT_BAND_ORDER) impact band across a list of findings,
72
+ * `undefined` when the list is empty: shared by every widget that needs a
73
+ * single band to badge/color a group of findings (Topbar, ShuffleIO,
74
+ * GcPressure, TaskSkew, Failures, ConfigAudit, Alerts).
75
+ * @param {{ impactBand: 'critical' | 'warning' | 'info' }[]} findings
76
+ * @returns {'critical' | 'warning' | 'info' | undefined}
77
+ */
78
+ export function worstImpactBand(
79
+ findings ,
80
+ ) {
81
+ let worst;
82
+ for (const f of findings) {
83
+ if (worst === undefined || IMPACT_BAND_ORDER[f.impactBand] < IMPACT_BAND_ORDER[worst]) worst = f.impactBand;
84
+ }
85
+ return worst;
86
+ }
87
+
88
+ export function escHtml(str ) {
89
+ return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
90
+ }
91
+
92
+ // Reduces an embedded HDFS/S3/absolute-path fragment in a string to its
93
+ // basename (e.g. "Scan ExistingRDD ... - hdfs://host/a/b/c" -> "c"). Strings
94
+ // with no such fragment pass through unchanged; this is NOT a general
95
+ // truncator. Shared by cache-utilization.js (RDD names), detectors.js (plan
96
+ // node names embedded in recommendation text), and stage-detail.js (plan
97
+ // node detail labels).
98
+ export function pathBasename(str ) {
99
+ const s = String(str);
100
+ const m = s.match(/((?:hdfs?|s3[an]?):\/\/\S+|\/[^\s,)]+)/i);
101
+ if (!m) return s;
102
+ const segs = m[1].replace(/[/,]+$/, '').split('/').filter(Boolean);
103
+ return segs[segs.length - 1] || s;
104
+ }
105
+
106
+ // "collect at /u02/hadoop/.../utils.py:1869" -> "collect at utils.py:1869". Splits
107
+ // on the last " at " (Spark's own callsite format is "<operation> at <location>"),
108
+ // then reduces the location to its basename via pathBasename (whose match already
109
+ // includes a trailing ":<line>", since colons aren't excluded from its regex).
110
+ export function trimCallsite(callsite ) {
111
+ const idx = callsite.lastIndexOf(' at ');
112
+ if (idx === -1) return callsite;
113
+ const operation = callsite.slice(0, idx);
114
+ const location = callsite.slice(idx + 4);
115
+ return `${operation} at ${pathBasename(location)}`;
116
+ }
117
+
118
+ // recent-files ids are `${name}::${size}::${lastModified}`: show just the
119
+ // name. Preferred over `app.name`, which is usually identical across the two
120
+ // runs being compared and so wouldn't distinguish baseline from candidate.
121
+ export function runLabel(id ) {
122
+ return id.split('::')[0] || id;
123
+ }
124
+
125
+ export function formatBytes(bytes ) {
126
+ if (!bytes || bytes <= 0) return '—';
127
+ if (bytes >= 1e9) return `${(bytes / 1e9).toFixed(1)} GB`;
128
+ if (bytes >= 1e6) return `${(bytes / 1e6).toFixed(0)} MB`;
129
+ return `${(bytes / 1e3).toFixed(0)} KB`;
130
+ }
131
+
132
+ // Spark reports some duration fields (e.g. executorCpuTime) in nanoseconds
133
+ // while most of this codebase works in milliseconds; convert at the source
134
+ // rather than trusting call sites to remember the unit.
135
+ export function nsToMs(ns ) {
136
+ return ns / 1e6;
137
+ }
138
+
139
+ export function formatDuration(ms ) {
140
+ if (!ms || ms <= 0) return '—';
141
+ if (ms < 1000) return `${Math.round(ms)}ms`;
142
+ if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
143
+ return `${Math.floor(ms / 60000)}m ${Math.floor((ms % 60000) / 1000)}s`;
144
+ }
145
+
146
+ export function recommendPartitions(stage ) {
147
+ const bytes = stage.shuffleReadBytes ?? 0;
148
+ if (bytes <= 0) return null;
149
+ const recommended = Math.min(MAX_RECOMMENDED, Math.ceil(bytes / TARGET_PARTITION_BYTES));
150
+ const current = stage.taskCount ?? 0;
151
+ if (recommended <= current * MEANINGFUL_RATIO) return null;
152
+ return { recommended, current };
153
+ }
154
+
155
+ export function buildHistogram(values , bins ) {
156
+ if (values.length === 0) return { labels: [], data: [] };
157
+ let min = values[0], max = values[0];
158
+ for (let i = 1; i < values.length; i++) {
159
+ if (values[i] < min) min = values[i];
160
+ if (values[i] > max) max = values[i];
161
+ }
162
+ const binSize = (max - min) / bins || 1;
163
+ const counts = new Array(bins).fill(0);
164
+ for (const v of values) counts[Math.min(Math.floor((v - min) / binSize), bins - 1)]++;
165
+ const labels = counts.map((_, i) => Math.round(min + i * binSize));
166
+ return { labels, data: counts };
167
+ }
@@ -0,0 +1,50 @@
1
+
2
+ import { STRAGGLER_FLOOR_PCT_WARN, STRAGGLER_FLOOR_PCT_CRIT } from './detectors.js';
3
+
4
+ // Reused from `straggler`'s own thresholds (detectors.ts's
5
+ // `floorPctWarn`/`floorPctCrit`, marked `NOT SOURCED: unvalidated` there
6
+ // already): applying the same, already-accepted noise floor globally rather
7
+ // than inventing a second, unrelated cutoff. Imported (not re-literaled) so
8
+ // the two can't drift out of sync.
9
+ const IMPACT_FLOOR_PCT_WARN = STRAGGLER_FLOOR_PCT_WARN;
10
+ const IMPACT_FLOOR_PCT_CRIT = STRAGGLER_FLOOR_PCT_CRIT;
11
+
12
+ function appDurationMs(app ) {
13
+ if (app?.startTime == null || app?.endTime == null) return null;
14
+ const durationMs = app.endTime - app.startTime;
15
+ return durationMs > 0 ? durationMs : null;
16
+ }
17
+
18
+ /**
19
+ * Overwrites `.impactBand` for any finding with a quantified wall-clock
20
+ * estimate, grading it purely by recoverable time as a fraction of the
21
+ * run's total duration: a full replace (can move a finding's fixed fallback
22
+ * band in either direction once real recoverable time is known), not a
23
+ * promote-only floor. Findings with no `wallClock` estimate
24
+ * (`resourceOnly`/`informational` basis) or an unknown/zero app duration are
25
+ * left exactly as the detector set them: there is no comparable figure to
26
+ * grade them by, so the detector's own fixed classification stands as the
27
+ * final value. Mutates in place and returns the same array, matching
28
+ * `estimateImpact`'s own signature style.
29
+ *
30
+ * Grades `wallClock.high`, matching `triage-target.ts`/`FixTheseFirst.tsx`/
31
+ * `formatImpactEstimateCompact`, which already rank and display severity by
32
+ * the same optimistic figure. This is a deliberate split from
33
+ * `view/impact-sort.ts`'s `'impact'` sort mode, which ranks by `.low`
34
+ * instead: severity grading (this function) wants the best-case number a fix
35
+ * could realistically claim, while the sort wants the guaranteed floor so an
36
+ * optimistic-but-contended finding never outranks a smaller but certain one.
37
+ * Same range, two different fields for two different questions — not an
38
+ * inconsistency to reconcile.
39
+ */
40
+ export function deriveImpactBand(findings , app ) {
41
+ const durationMs = appDurationMs(app);
42
+ if (durationMs == null) return findings;
43
+ for (const finding of findings) {
44
+ const recoverableMs = finding.impactEstimate?.wallClock?.high;
45
+ if (recoverableMs == null) continue;
46
+ const pct = recoverableMs / durationMs;
47
+ finding.impactBand = pct >= IMPACT_FLOOR_PCT_CRIT ? 'critical' : pct >= IMPACT_FLOOR_PCT_WARN ? 'warning' : 'info';
48
+ }
49
+ return findings;
50
+ }
@@ -0,0 +1,428 @@
1
+
2
+ import { computeOccupancy, estimateSingleStage, estimateMultiStage, } from './occupancy.js';
3
+ import { detectorCatalog } from './detectors.js';
4
+
5
+ // Assumed shuffle-network throughput, ~1 Gbps. Starting assumption; tune against the
6
+ // Task 10 real-log spot-check (docs/architecture.md's Impact estimation section).
7
+ const SHUFFLE_THROUGHPUT_BPS = 125_000_000;
8
+ // Assumed disk I/O throughput for spilled data, ~200 MB/s (conservative HDD/SSD blend).
9
+ const SPILL_IO_THROUGHPUT_BPS = 200_000_000;
10
+ // Spark's classic recommended shuffle partition size.
11
+ const IDEAL_BYTES_PER_PARTITION_TASK = 128 * 1024 * 1024;
12
+ // Assumed per-task scheduling/launch overhead.
13
+ const TASK_SCHEDULING_OVERHEAD_MS = 50;
14
+ // Assumed per-file open latency (small-file overhead).
15
+ const FILE_OPEN_OVERHEAD_MS = 10;
16
+ // Assumed broadcast-transfer bandwidth, shared with overBroadcast/underBroadcast.
17
+ const BROADCAST_BANDWIDTH_BPS = 125_000_000;
18
+ // Assumed per-non-local-task network-fetch penalty, reported as extra core-time.
19
+ const NETWORK_FETCH_PENALTY_MS = 20;
20
+ // Assumed executor JVM+container startup overhead.
21
+ const EXECUTOR_STARTUP_OVERHEAD_MS = 15000;
22
+ // Assumed re-read throughput, shared by cachingOpportunity and cacheUtilization.
23
+ const RE_READ_THROUGHPUT_BPS = 125_000_000;
24
+
25
+ // The stageSlowness detector (src/detectors.ts) flags a stage once its wall-clock
26
+ // duration reaches `infoMin` minutes; that's the point beyond which the stage's time
27
+ // stops being "normal", so it's also the floor for the waste this estimate reports.
28
+ // Read from the detector's own catalog entry (not a duplicated literal) so the two
29
+ // stay in sync automatically if the detector's threshold ever changes.
30
+ const STAGE_SLOWNESS_THRESHOLD_MINUTES = (() => {
31
+ const infoMin = detectorCatalog().find((d) => d.type === 'stageSlowness')?.thresholds?.infoMin;
32
+ if (typeof infoMin !== 'number') {
33
+ throw new Error("impact-estimator: stageSlowness detector's 'infoMin' threshold not found in detectorCatalog()");
34
+ }
35
+ return infoMin;
36
+ })();
37
+
38
+ // A finding with no quantifiable magnitude falls back to 'informational'; one that
39
+ // still yields a rawWaste figure (independent of any stage window) falls back to
40
+ // 'resourceOnly'. Never a fake {low: 0, high: 0}: wallClock is null in both cases.
41
+ function costOnly(estimateMethod , rawWaste ) {
42
+ return rawWaste
43
+ ? { basis: 'resourceOnly', wallClock: null, estimateMethod, rawWaste }
44
+ : { basis: 'informational', wallClock: null, estimateMethod };
45
+ }
46
+
47
+ function singleStageImpact(
48
+ wasteMs ,
49
+ stageId ,
50
+ stages ,
51
+ occupancy ,
52
+ estimateMethod ,
53
+ rawWaste ,
54
+ ) {
55
+ const est = estimateSingleStage(wasteMs, stageId, stages , occupancy);
56
+ if (est) return { basis: est.basis, wallClock: est.wallClock, estimateMethod, rawWaste };
57
+ return costOnly(estimateMethod, rawWaste); // stage excluded from the sweep (duration <= 0)
58
+ }
59
+
60
+ function stageMappableWasteOrCostOnly(
61
+ wasteMs ,
62
+ stageIds ,
63
+ stages ,
64
+ occupancy ,
65
+ ) {
66
+ const rawWaste = wasteMs > 0 ? { value: wasteMs, unit: 'ms' } : undefined;
67
+ if (!stageIds || stageIds.length === 0) {
68
+ return costOnly('modeled', rawWaste);
69
+ }
70
+ // One waste event spread over a span of stages, not N independent wastes:
71
+ // apportion it evenly so estimateMultiStage's union cap doesn't have to
72
+ // absorb the same amount claimed once per stage.
73
+ const perStageWasteMs = wasteMs / stageIds.length;
74
+ const wasteMsByStage = new Map(stageIds.map((id) => [id, perStageWasteMs]));
75
+ const est = estimateMultiStage(stageIds, wasteMsByStage, stages , occupancy);
76
+ if (!est) return costOnly('modeled', rawWaste); // every stage excluded from the sweep
77
+ return { basis: est.basis, wallClock: est.wallClock, estimateMethod: 'modeled', rawWaste };
78
+ }
79
+
80
+ /**
81
+ * Per-finding-type dispatch, populated incrementally: each formula task adds one
82
+ * case here. A type with no case stays uncovered (no impactEstimate attached),
83
+ * every type this design covers must eventually get one, per the Global Constraints'
84
+ * "no silent omissions" rule; docs/architecture.md's Impact estimation table is the
85
+ * authoritative completeness check, not this switch by itself.
86
+ */
87
+ function computeEstimateForFinding(
88
+ finding ,
89
+ stages ,
90
+ occupancy ,
91
+ ) {
92
+ switch (finding.type) {
93
+ case 'retryWaste': {
94
+ // The waste figure lives on the Stage, not the Finding: the detector only
95
+ // re-publishes it as `metric`/`value` (src/detectors.ts's retryWaste entry).
96
+ if (finding.stageId == null) return null;
97
+ const stage = stages.get(finding.stageId);
98
+ if (!stage) return null;
99
+ const wasteMs = (stage.retryWasteMs ) ?? 0;
100
+ return singleStageImpact(wasteMs, finding.stageId, stages, occupancy, 'measured', { value: wasteMs, unit: 'ms' });
101
+ }
102
+ case 'speculationWaste': {
103
+ if (finding.stageId == null) return null;
104
+ const stage = stages.get(finding.stageId);
105
+ if (!stage) return null;
106
+ const wasteMs = (stage.speculationWasteMs ) ?? 0;
107
+ return singleStageImpact(wasteMs, finding.stageId, stages, occupancy, 'measured', { value: wasteMs, unit: 'ms' });
108
+ }
109
+ case 'coldStart': {
110
+ // The detector reports the gap as `metric: 'startupGapSeconds', value: <seconds>`.
111
+ if (typeof finding.value !== 'number') return null;
112
+ const wasteMs = finding.value * 1000;
113
+ // Time before any task starts can never overlap any stage; a genuine,
114
+ // unclipped point estimate, not tied to any stage's own gate (coldStart is
115
+ // app-scoped, stageId: null): see Non-goals in the original design spec.
116
+ return { basis: 'serial', wallClock: { low: wasteMs, high: wasteMs }, estimateMethod: 'measured' };
117
+ }
118
+ case 'gc': {
119
+ if (finding.stageId == null) return null;
120
+ const stage = stages.get(finding.stageId);
121
+ if (!stage) return null;
122
+ const stageDurationMs = (stage.completedAt ?? 0) - (stage.submittedAt ?? 0);
123
+ const executorRunTime = stage.executorRunTime ?? 0;
124
+ const jvmGCTime = stage.jvmGCTime ?? 0;
125
+ // The raw cross-task core-time sum, before any conversion: the one figure here
126
+ // that is straight from the log rather than modeled.
127
+ const rawWaste = { value: jvmGCTime, unit: 'coreMs' } ;
128
+ if (executorRunTime <= 0 || stageDurationMs <= 0) {
129
+ return costOnly('modeled', rawWaste);
130
+ }
131
+ const avgConcurrency = executorRunTime / stageDurationMs;
132
+ // jvmGCTime is a cross-task core-time sum, the same aggregation shape as
133
+ // executorRunTime; dividing by the stage's own average concurrency converts it
134
+ // to an approximate wall-clock figure. This rides on measured inputs but is a
135
+ // modeled approximation, not an exact reconstruction: hence 'modeled', and see
136
+ // docs/architecture.md's spot-check note next to this formula.
137
+ const wasteMs = jvmGCTime / avgConcurrency;
138
+ return singleStageImpact(wasteMs, finding.stageId, stages, occupancy, 'modeled', rawWaste);
139
+ }
140
+ case 'skew': {
141
+ if (finding.stageId == null) return null;
142
+ const stage = stages.get(finding.stageId);
143
+ if (!stage) return null;
144
+ const p50 = stage.taskDurationP50 ?? 0;
145
+ // computeSkewRatio's own metric labels (src/detectors.ts): 'P95/median' or 'max/median'.
146
+ const usesP95Branch = finding.metric === 'P95/median';
147
+ const wasteMs = Math.max(0, usesP95Branch ? (stage.taskDurationP95 ?? 0) - p50 : (stage.taskDurationMax ?? 0) - p50);
148
+ return singleStageImpact(wasteMs, finding.stageId, stages, occupancy, 'measured', { value: wasteMs, unit: 'ms' });
149
+ }
150
+ case 'straggler':
151
+ case 'stageShape': {
152
+ // NOTE: this is a shared case for two finding types. straggler (which has
153
+ // no `rule` field to check) falls through to the max-P50 wall-clock
154
+ // computation below; every stageShape rule (including taskStageSkew) gets
155
+ // its own resourceOnly formula here, each returning early (not `break`,
156
+ // which would fall off the end of this function's switch and implicitly
157
+ // return `undefined` instead of `null` since the switch is the function's
158
+ // final statement).
159
+ if (finding.type === 'stageShape') {
160
+ if (finding.rule === 'lowParallelism') {
161
+ const stage = stages.get(finding.stageId );
162
+ if (!stage) return null;
163
+ const stageDurationMs = (stage.completedAt ?? 0) - (stage.submittedAt ?? 0);
164
+ const idleCoreMs =
165
+ Math.max(0, ((finding.totalCores ) ?? 0) - (stage.taskCount ?? 0)) * stageDurationMs;
166
+ // Real per-stage data (cores, task count, duration), no assumed constant.
167
+ return costOnly('measured', { value: idleCoreMs, unit: 'coreMs' });
168
+ }
169
+ if (finding.rule === 'dataExplosion') {
170
+ const stage = stages.get(finding.stageId );
171
+ if (!stage) return null;
172
+ const excessBytes = Math.max(0, (stage.outputBytes ?? 0) - (stage.inputBytes ?? 0));
173
+ // Measured input/output byte counts, no assumed constant.
174
+ return costOnly('measured', { value: excessBytes, unit: 'bytes' });
175
+ }
176
+ if (finding.rule === 'taskStageSkew') {
177
+ const stage = stages.get(finding.stageId );
178
+ if (!stage) return null;
179
+ const totalCores = (finding.totalCores ) ?? 0;
180
+ const taskCount = stage.taskCount ?? 0;
181
+ // Cores idle during the straggler's tail, at achieved concurrency (not full
182
+ // cluster capacity, which is lowParallelism's own territory): satisfying this
183
+ // rule's trigger condition mathematically forces the occupancy-clipped
184
+ // wall-clock estimate to zero on every firing, so this is resourceOnly, not a
185
+ // wall-clock claim (see the Overlap caveat section in
186
+ // docs-site/contributor-guide/architecture/impact-estimation.md).
187
+ const idleCoreMs =
188
+ Math.max(0, Math.min(totalCores, taskCount) - 1) *
189
+ Math.max(0, (stage.taskDurationMax ?? 0) - (stage.taskDurationP50 ?? 0));
190
+ return costOnly('measured', { value: idleCoreMs, unit: 'coreMs' });
191
+ }
192
+ return null;
193
+ }
194
+ if (finding.stageId == null) return null;
195
+ const stage = stages.get(finding.stageId);
196
+ if (!stage) return null;
197
+ const wasteMs = Math.max(0, (stage.taskDurationMax ?? 0) - (stage.taskDurationP50 ?? 0));
198
+ return singleStageImpact(wasteMs, finding.stageId, stages, occupancy, 'measured', { value: wasteMs, unit: 'ms' });
199
+ }
200
+ case 'slowHost': {
201
+ // Three duration-based shapes, each carrying its absolute-ms figure under a
202
+ // different field (`value` is a ratio or a share in all three, never ms):
203
+ // - the per-host mean branch, discriminated by `metric` (it sets no `variant`)
204
+ // - the duration-share branch, discriminated by `variant`
205
+ // - the multi-dimension branch, only for its taskTime dimension
206
+ // Every other shape (the byte-based multiDim dimensions) has no absolute
207
+ // figure today, so it stays informational.
208
+ const absoluteMs =
209
+ finding.metric === 'hostMeanRatio' || finding.variant === 'durationShare'
210
+ ? (finding.hostMeanMs )
211
+ : finding.variant === 'multiDim' && finding.dimension === 'taskTime'
212
+ ? (finding.execMaxValue )
213
+ : null;
214
+ if (absoluteMs == null) {
215
+ return costOnly('none'); // byte-based multiDim dims: no absolute figure today, no model applied
216
+ }
217
+ if (finding.stageId == null) return null;
218
+ const stage = stages.get(finding.stageId);
219
+ if (!stage) return null;
220
+ const wasteMs = Math.max(0, absoluteMs - (stage.taskDurationP50 ?? 0));
221
+ return singleStageImpact(wasteMs, finding.stageId, stages, occupancy, 'measured', { value: wasteMs, unit: 'ms' });
222
+ }
223
+ case 'duplicatePlanSubtree': {
224
+ const stageIds = finding.stageIds ;
225
+ if (!stageIds || stageIds.length === 0) return null;
226
+ // The detector reports metric: 'subtreeOccurrences', value: <occurrences>, always
227
+ // >= 2 (its own `minOccurrences` threshold). Only the repeats past the first are
228
+ // redundant: computing the subtree once is real work, so the waste is
229
+ // (occurrences - 1) / occurrences of the contributing stages' time, not all of it.
230
+ const occurrences = typeof finding.value === 'number' ? finding.value : 0;
231
+ // Defensive only: the detector's own `minOccurrences` threshold guarantees occurrences
232
+ // >= 2 on real data, so this is a malformed-`value` fallback, not a real formula run.
233
+ if (occurrences < 2) return costOnly('none');
234
+ const redundantFraction = (occurrences - 1) / occurrences;
235
+ const wasteMsByStage = new Map ();
236
+ for (const id of stageIds) {
237
+ const s = stages.get(id);
238
+ if (s) {
239
+ const durationMs = Math.max(0, (s.completedAt ?? 0) - (s.submittedAt ?? 0));
240
+ wasteMsByStage.set(id, durationMs * redundantFraction);
241
+ }
242
+ }
243
+ const totalWasteMs = [...wasteMsByStage.values()].reduce((sum, ms) => sum + ms, 0);
244
+ const rawWaste = { value: totalWasteMs, unit: 'ms' };
245
+ const est = estimateMultiStage(stageIds, wasteMsByStage, stages , occupancy);
246
+ if (!est) return costOnly('measured', rawWaste);
247
+ return { basis: est.basis, wallClock: est.wallClock, estimateMethod: 'measured', rawWaste };
248
+ }
249
+ case 'shuffle': {
250
+ if (finding.stageId == null) return null;
251
+ const stage = stages.get(finding.stageId);
252
+ if (!stage) return null;
253
+ const shuffleReadBytes = stage.shuffleReadBytes ?? 0;
254
+ const wasteMs = (shuffleReadBytes / SHUFFLE_THROUGHPUT_BPS) * 1000;
255
+ // The measured byte volume driving the modeled ms figure above.
256
+ return singleStageImpact(wasteMs, finding.stageId, stages, occupancy, 'modeled', { value: shuffleReadBytes, unit: 'bytes' });
257
+ }
258
+ case 'spill': {
259
+ if (finding.stageId == null) return null;
260
+ const stage = stages.get(finding.stageId);
261
+ if (!stage) return null;
262
+ const diskBytesSpilled = stage.diskBytesSpilled ?? 0;
263
+ const wasteMs = (diskBytesSpilled / SPILL_IO_THROUGHPUT_BPS) * 1000;
264
+ // Surfaces the number the formula actually uses: the finding's displayed `metric`
265
+ // is memoryBytesSpilled, but disk spill is what costs I/O time.
266
+ return singleStageImpact(wasteMs, finding.stageId, stages, occupancy, 'modeled', { value: diskBytesSpilled, unit: 'bytes' });
267
+ }
268
+ case 'stageSlowness': {
269
+ if (finding.stageId == null) return null;
270
+ const stage = stages.get(finding.stageId);
271
+ if (!stage) return null;
272
+ const stageDurationMs = (stage.completedAt ?? 0) - (stage.submittedAt ?? 0);
273
+ const wasteMs = Math.max(0, stageDurationMs - STAGE_SLOWNESS_THRESHOLD_MINUTES * 60000);
274
+ return singleStageImpact(wasteMs, finding.stageId, stages, occupancy, 'modeled', { value: wasteMs, unit: 'ms' });
275
+ }
276
+ case 'partitionSizing': {
277
+ if (finding.stageId == null) return null;
278
+ const stage = stages.get(finding.stageId);
279
+ if (!stage) return null;
280
+ let wasteMs = 0;
281
+ if (finding.rule === 'maxPartitionTooBig') {
282
+ wasteMs = ((stage.shuffleReadMax ?? 0) / SHUFFLE_THROUGHPUT_BPS) * 1000;
283
+ } else if (finding.rule === 'shufflePartitionSkew') {
284
+ const delta = Math.max(0, (stage.shuffleReadMax ?? 0) - (stage.shuffleReadP50 ?? 0));
285
+ wasteMs = (delta / SHUFFLE_THROUGHPUT_BPS) * 1000;
286
+ } else if (finding.rule === 'lowShuffleParallelism') {
287
+ const targetTaskCount = Math.ceil((stage.shuffleReadBytes ?? 0) / IDEAL_BYTES_PER_PARTITION_TASK);
288
+ const taskCount = stage.taskCount ?? 0;
289
+ if (targetTaskCount > taskCount && taskCount > 0) {
290
+ const stageDurationMs = Math.max(0, (stage.completedAt ?? 0) - (stage.submittedAt ?? 0));
291
+ // Too few shuffle partitions means each task processes more bytes than the ideal
292
+ // target, doing serially what more partitions would let run concurrently: the waste
293
+ // is that serialized work, not the scheduling cost of the tasks you'd add to fix it
294
+ // (adding tasks INCURS overhead, it doesn't recover any). Model the achievable
295
+ // duration at target parallelism by scaling down proportionally to the partition
296
+ // shortfall, and claim the difference.
297
+ wasteMs = stageDurationMs * (1 - taskCount / targetTaskCount);
298
+ }
299
+ } else {
300
+ return null;
301
+ }
302
+ return singleStageImpact(wasteMs, finding.stageId, stages, occupancy, 'modeled', { value: wasteMs, unit: 'ms' });
303
+ }
304
+ case 'tinyTask': {
305
+ if (finding.stageId == null) return null;
306
+ const stage = stages.get(finding.stageId);
307
+ if (!stage) return null;
308
+ const taskCount = stage.taskCount ?? 0;
309
+ const excessTaskCount = Math.max(0, taskCount - Math.round(taskCount / 10));
310
+ const wasteMs = excessTaskCount * TASK_SCHEDULING_OVERHEAD_MS;
311
+ return singleStageImpact(wasteMs, finding.stageId, stages, occupancy, 'modeled', { value: wasteMs, unit: 'ms' });
312
+ }
313
+ case 'smallFiles': {
314
+ const wasteMs = ((finding.fileCount ) ?? 0) * FILE_OPEN_OVERHEAD_MS;
315
+ return stageMappableWasteOrCostOnly(wasteMs, finding.stageIds , stages, occupancy);
316
+ }
317
+ case 'overBroadcast': {
318
+ // metric: 'broadcastBytes', value: <bytes>.
319
+ const wasteMs = (((finding.value ) ?? 0) / BROADCAST_BANDWIDTH_BPS) * 1000;
320
+ return stageMappableWasteOrCostOnly(wasteMs, finding.stageIds , stages, occupancy);
321
+ }
322
+ case 'underBroadcast': {
323
+ // metric: 'smallerSideBytes', value: <bytes of the smaller join side>.
324
+ const wasteMs = (((finding.value ) ?? 0) / BROADCAST_BANDWIDTH_BPS) * 1000;
325
+ return stageMappableWasteOrCostOnly(wasteMs, finding.stageIds , stages, occupancy);
326
+ }
327
+ case 'memoryUtilization': {
328
+ // The wasteModel variant reports metric: 'wastedMBSeconds', value: <MB-seconds>.
329
+ if (finding.variant === 'wasteModel' && typeof finding.value === 'number') {
330
+ return costOnly('measured', { value: finding.value, unit: 'mbSeconds' });
331
+ }
332
+ if (finding.variant === 'idleCores') {
333
+ // Idle core-time priced as memory held but unused: the same MB-seconds unit
334
+ // the wasteModel variant reports, so the two are comparable.
335
+ const idleRateFraction = finding.idleRateFraction ;
336
+ const allocatedMB = finding.allocatedMB ;
337
+ const peakExecutors = finding.peakExecutors ;
338
+ const appDurationMs = finding.appDurationMs ;
339
+ if (idleRateFraction != null && allocatedMB != null && peakExecutors != null && appDurationMs != null) {
340
+ const wastedMBSeconds = idleRateFraction * allocatedMB * peakExecutors * (appDurationMs / 1000);
341
+ return costOnly('modeled', { value: wastedMBSeconds, unit: 'mbSeconds' });
342
+ }
343
+ return costOnly('modeled');
344
+ }
345
+ // Only the over-provisioned band is a waste; the near-capacity band is an OOM-risk
346
+ // signal with no magnitude to report, and the dataUnavailable shape has no inputs
347
+ // at all: both stay informational.
348
+ if (finding.variant === 'memoryBand' && finding.rule === 'heapOverProvisioned') {
349
+ const allocatedBytes = finding.allocatedBytes ;
350
+ const heap = finding.heap ;
351
+ const appDurationMs = finding.appDurationMs ;
352
+ if (allocatedBytes != null && heap != null && appDurationMs != null) {
353
+ const unusedMB = (allocatedBytes - heap) / (1024 * 1024);
354
+ const wastedMBSeconds = unusedMB * (appDurationMs / 1000);
355
+ return costOnly('modeled', { value: wastedMBSeconds, unit: 'mbSeconds' });
356
+ }
357
+ }
358
+ return costOnly('modeled');
359
+ }
360
+ case 'utilization': {
361
+ const fraction = finding.utilizationFraction ;
362
+ const appDurationMs = finding.appDurationMs ;
363
+ const totalCores = finding.totalCores ;
364
+ if (fraction == null || appDurationMs == null || totalCores == null) {
365
+ return costOnly('measured');
366
+ }
367
+ const idleCoreHours = (1 - fraction) * appDurationMs * totalCores / 3.6e6;
368
+ return costOnly('measured', { value: idleCoreHours, unit: 'coreHours' });
369
+ }
370
+ case 'coreLocality': {
371
+ const nonLocal = (finding.nonLocalTaskCount ) ?? 0;
372
+ const coreMs = nonLocal * NETWORK_FETCH_PENALTY_MS;
373
+ return costOnly('modeled', { value: coreMs, unit: 'coreMs' });
374
+ }
375
+ case 'autoscalingChurn': {
376
+ const shortLived = (finding.shortLivedExecutorCount ) ?? 0;
377
+ const executorHours = (shortLived * EXECUTOR_STARTUP_OVERHEAD_MS) / 3.6e6;
378
+ return costOnly('modeled', { value: executorHours, unit: 'coreHours' });
379
+ }
380
+ case 'configAudit': {
381
+ return costOnly('none'); // purely informational: no waste model applied
382
+ }
383
+ case 'jobFailureRate': {
384
+ const failedJobs = (finding.failedJobs ) ?? 0;
385
+ const avgJobDurationMs = (finding.avgJobDurationMs ) ?? 0;
386
+ const coreHoursIsh = (failedJobs * avgJobDurationMs) / 3.6e6;
387
+ return costOnly('modeled', { value: coreHoursIsh, unit: 'coreHours' });
388
+ }
389
+ case 'cachingOpportunity': {
390
+ const totalReadBytes = (finding.totalReadBytes ) ?? 0;
391
+ const wasteMs = (totalReadBytes / RE_READ_THROUGHPUT_BPS) * 1000;
392
+ return costOnly('modeled', { value: wasteMs, unit: 'ms' });
393
+ }
394
+ case 'cacheUtilization': {
395
+ const memorySize = (finding.memorySize ) ?? 0;
396
+ const diskSize = (finding.diskSize ) ?? 0;
397
+ const numCachedPartitions = (finding.numCachedPartitions ) ?? 0;
398
+ const numPartitions = (finding.numPartitions ) ?? 0;
399
+ const numUncachedPartitions = Math.max(0, numPartitions - numCachedPartitions);
400
+ const cachedBytes = memorySize + diskSize;
401
+ // Extrapolate the never-cached partitions' size from the CACHED partitions' own
402
+ // average size (uncached/cached, not uncached/total: `numCachedPartitions` partitions
403
+ // produced `cachedBytes`, not all `numPartitions` of them). `diskSize` is added once
404
+ // more on its own: those bytes are already cached, but on disk rather than memory, so
405
+ // re-reading them still costs I/O the way a genuinely-uncached partition would.
406
+ const uncachedBytes = numCachedPartitions > 0 ? (cachedBytes / numCachedPartitions) * numUncachedPartitions : 0;
407
+ const uncachedOrSpilledBytes = uncachedBytes + diskSize;
408
+ const wasteMs = (uncachedOrSpilledBytes / RE_READ_THROUGHPUT_BPS) * 1000;
409
+ return costOnly('modeled', { value: wasteMs, unit: 'ms' });
410
+ }
411
+ case 'stageFailed':
412
+ case 'failures':
413
+ case 'incompleteRun': {
414
+ return costOnly('none'); // purely informational: no waste model applied
415
+ }
416
+ default:
417
+ return null;
418
+ }
419
+ }
420
+
421
+ export function estimateImpact(findings , stages , totalCores = 0) {
422
+ const occupancy = computeOccupancy(stages , totalCores);
423
+ for (const f of findings) {
424
+ const estimate = computeEstimateForFinding(f, stages, occupancy);
425
+ if (estimate) f.impactEstimate = estimate;
426
+ }
427
+ return findings;
428
+ }