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