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,203 @@
1
+ import { computeEfficiencyModel } from '../efficiency-model.js';
2
+ import { computeSkewRatio, DETECTORS } from '../detectors.js';
3
+ import { IMPACT_BAND_ORDER } from '../format-utils.js';
4
+
5
+
6
+
7
+ const IMPACT_BANDS = Object.keys(IMPACT_BAND_ORDER) ;
8
+
9
+
10
+
11
+
12
+
13
+
14
+
15
+
16
+
17
+
18
+
19
+
20
+
21
+ const skewDetector = DETECTORS.find((d) => d.type === 'skew');
22
+ const SKEW_MIN_TASKS_FOR_P95 = skewDetector .thresholds .minTasksForP95 ;
23
+
24
+ function taskDataTrusted(appModel ) {
25
+ const entry = appModel.evidenceAvailability?.entries?.find((e) => e.key === 'taskCoreTime');
26
+ return entry?.state === 'present';
27
+ }
28
+
29
+ // `Finding.value` is `number | string` (some detectors, e.g. stageFailed/
30
+ // configAudit, put human-readable text there instead of a magnitude; see
31
+ // types.ts). The 'spill' findings this is used for are always numeric; the
32
+ // typeof guard below narrows without changing behavior for real input.
33
+ function maxFindingValue(catalog , type ) {
34
+ const values = catalog
35
+ .filter((f) => f.type === type)
36
+ .map((f) => f.value ?? 0)
37
+ .filter((v) => typeof v === 'number');
38
+ return values.length > 0 ? Math.max(...values) : null;
39
+ }
40
+
41
+ function checkRuntime(appModel , maxRuntimeMs ) {
42
+ const { startTime, endTime } = appModel.app ?? {};
43
+ if (startTime == null || endTime == null) {
44
+ return { name: 'max-runtime', status: 'inconclusive', detail: 'App start/end time not observed (run may not have finished).' };
45
+ }
46
+ const runtimeMs = endTime - startTime;
47
+ return runtimeMs > maxRuntimeMs
48
+ ? { name: 'max-runtime', status: 'violation', detail: `Runtime ${runtimeMs}ms exceeds budget ${maxRuntimeMs}ms.` }
49
+ : { name: 'max-runtime', status: 'pass', detail: `Runtime ${runtimeMs}ms within budget ${maxRuntimeMs}ms.` };
50
+ }
51
+
52
+ function checkSpill(appModel , catalog , maxSpillGb ) {
53
+ if (!taskDataTrusted(appModel)) {
54
+ return { name: 'max-spill', status: 'inconclusive', detail: 'No trustworthy task-level evidence to measure spill.' };
55
+ }
56
+ const maxBytes = maxFindingValue(catalog, 'spill') ?? 0;
57
+ const budgetBytes = maxSpillGb * 1024 ** 3;
58
+ return maxBytes > budgetBytes
59
+ ? { name: 'max-spill', status: 'violation', detail: `Peak stage spill ${maxBytes} bytes exceeds budget ${budgetBytes} bytes.` }
60
+ : { name: 'max-spill', status: 'pass', detail: `Peak stage spill ${maxBytes} bytes within budget ${budgetBytes} bytes.` };
61
+ }
62
+
63
+ function checkSkew(appModel , maxSkewRatio ) {
64
+ if (!taskDataTrusted(appModel)) {
65
+ return { name: 'max-skew', status: 'inconclusive', detail: 'No trustworthy task-level evidence to measure skew.' };
66
+ }
67
+ const stages = [...(appModel.stages?.values() ?? [])];
68
+ if (stages.length === 0) {
69
+ return { name: 'max-skew', status: 'inconclusive', detail: 'No stage data observed in this event log.' };
70
+ }
71
+ // Recompute the true ratio per stage (not from the finding catalog): the
72
+ // skew detector floors its findings at thresholds.ratioWarn (3), so a
73
+ // budget stricter than that floor could never be enforced by reading
74
+ // catalog findings alone.
75
+ const ratios = stages
76
+ .map((stage) => computeSkewRatio(stage, SKEW_MIN_TASKS_FOR_P95))
77
+ .filter((r) => r !== null)
78
+ .map((r) => r.ratio);
79
+ if (ratios.length === 0) {
80
+ return { name: 'max-skew', status: 'inconclusive', detail: 'No stage has a measurable task-duration median.' };
81
+ }
82
+ const maxRatio = Math.max(...ratios);
83
+ return maxRatio > maxSkewRatio
84
+ ? { name: 'max-skew', status: 'violation', detail: `Peak stage skew ratio ${maxRatio} exceeds budget ${maxSkewRatio}.` }
85
+ : { name: 'max-skew', status: 'pass', detail: `Peak stage skew ratio ${maxRatio} within budget ${maxSkewRatio}.` };
86
+ }
87
+
88
+ function checkFailedTaskRate(appModel , catalog , maxPct ) {
89
+ const finding = catalog.find((f) => f.type === 'jobFailureRate');
90
+ if (finding) {
91
+ const rate = (finding.taskFailureRate ) ?? 0;
92
+ return rate > maxPct
93
+ ? { name: 'max-failed-task-rate', status: 'violation', detail: `Task failure rate ${rate}% exceeds budget ${maxPct}%.` }
94
+ : { name: 'max-failed-task-rate', status: 'pass', detail: `Task failure rate ${rate}% within budget ${maxPct}%.` };
95
+ }
96
+ if ((appModel.jobs?.size ?? 0) === 0) {
97
+ return { name: 'max-failed-task-rate', status: 'inconclusive', detail: 'No job data observed in this event log.' };
98
+ }
99
+ // Jobs completed but the jobFailureRate detector never fired: job failure
100
+ // rate is below its own 10% info floor (src/detectors.js), so the task
101
+ // failure rate is implicitly low too. Known v1 limitation: a budget
102
+ // stricter than that floor cannot be enforced.
103
+ return { name: 'max-failed-task-rate', status: 'pass', detail: `No job-failure-rate finding: task failure rate is below the detector's reporting floor.` };
104
+ }
105
+
106
+ function checkEfficiency(appModel , minPct ) {
107
+ if (!taskDataTrusted(appModel)) {
108
+ return { name: 'min-efficiency', status: 'inconclusive', detail: 'No trustworthy task-level evidence to measure efficiency.' };
109
+ }
110
+ const model = computeEfficiencyModel({
111
+ app: appModel.app, stages: appModel.stages,
112
+ executorsAdded: appModel.executors.added, runAggregates: appModel.runAggregates,
113
+ });
114
+ if (model.wastagePct == null) {
115
+ return { name: 'min-efficiency', status: 'inconclusive', detail: 'Efficiency could not be computed (no available compute hours).' };
116
+ }
117
+ const efficiencyPct = 100 - model.wastagePct;
118
+ return efficiencyPct < minPct
119
+ ? { name: 'min-efficiency', status: 'violation', detail: `Efficiency ${efficiencyPct}% below budget ${minPct}%.` }
120
+ : { name: 'min-efficiency', status: 'pass', detail: `Efficiency ${efficiencyPct}% meets budget ${minPct}%.` };
121
+ }
122
+
123
+ function checkRegression(comparison , maxRegressionPct , regressionMetric ) {
124
+ const row = comparison.metrics.find((m) => m.key === regressionMetric);
125
+ if (!row || row.direction === 'unavailable' || row.baseline == null || row.delta == null) {
126
+ return { name: 'max-regression', status: 'inconclusive', detail: `Metric "${regressionMetric}" is unavailable for this comparison.` };
127
+ }
128
+ // A neutral-direction metric (inputBytes, outputBytes, taskCount,
129
+ // executorsAdded — see NEUTRAL_METRIC_KEYS in run-comparison.ts) measures
130
+ // workload volume, not performance: an increase isn't a regression, so a
131
+ // regression budget can't be meaningfully evaluated against it either way.
132
+ if (row.direction === 'neutral') {
133
+ return { name: 'max-regression', status: 'inconclusive', detail: `Metric "${regressionMetric}" measures workload volume, not performance: it has no regression direction to check.` };
134
+ }
135
+ if (row.direction !== 'regression') {
136
+ return { name: 'max-regression', status: 'pass', detail: `Metric "${regressionMetric}" did not regress (${row.direction}).` };
137
+ }
138
+ const pct = row.baseline === 0 ? Infinity : Math.abs(row.delta / row.baseline) * 100;
139
+ const pctLabel = row.baseline === 0
140
+ ? `regressed from 0 to ${row.delta} (was absent/zero in baseline)`
141
+ : `regressed ${pct.toFixed(1)}%`;
142
+ return pct > maxRegressionPct
143
+ ? { name: 'max-regression', status: 'violation', detail: `Metric "${regressionMetric}" ${pctLabel}, exceeding budget ${maxRegressionPct}%.` }
144
+ : { name: 'max-regression', status: 'pass', detail: `Metric "${regressionMetric}" ${pctLabel}, within budget ${maxRegressionPct}%.` };
145
+ }
146
+
147
+ function checkFailOnIntroduced(comparison , band ) {
148
+ if (band !== 'all' && !IMPACT_BANDS.includes(band )) {
149
+ return { name: 'fail-on-introduced', status: 'inconclusive', detail: `Impact band "${band}" is not recognized (expected "all" or one of ${IMPACT_BANDS.join(', ')}).` };
150
+ }
151
+ const matches = band === 'all'
152
+ ? comparison.findings.introduced
153
+ : comparison.findings.introduced.filter((f) => f.impactBand === band);
154
+ return matches.length > 0
155
+ ? { name: 'fail-on-introduced', status: 'violation', detail: `${matches.length} introduced finding(s) match "${band}".` }
156
+ : { name: 'fail-on-introduced', status: 'pass', detail: `No introduced findings match "${band}".` };
157
+ }
158
+
159
+ // Shared by both comparison-dependent budgets below: each needs the same
160
+ // "no comparison yet -> inconclusive" fallback instead of actually checking.
161
+ function pushComparisonBudget(
162
+ results ,
163
+ comparison ,
164
+ name ,
165
+ check ,
166
+ ) {
167
+ results.push(comparison ? check(comparison) : { name, status: 'inconclusive', detail: 'No baseline comparison available to evaluate this budget.' });
168
+ }
169
+
170
+ export function evaluateBudgets({ appModel, catalog, budgets, comparison }
171
+
172
+ ) {
173
+ const results = [];
174
+ if (Number.isFinite(budgets.maxRuntimeMs)) results.push(checkRuntime(appModel, budgets.maxRuntimeMs ));
175
+ if (Number.isFinite(budgets.maxSpillGb)) results.push(checkSpill(appModel, catalog, budgets.maxSpillGb ));
176
+ if (Number.isFinite(budgets.maxSkewRatio)) results.push(checkSkew(appModel, budgets.maxSkewRatio ));
177
+ if (Number.isFinite(budgets.maxFailedTaskRatePct)) results.push(checkFailedTaskRate(appModel, catalog, budgets.maxFailedTaskRatePct ));
178
+ if (Number.isFinite(budgets.minEfficiencyPct)) results.push(checkEfficiency(appModel, budgets.minEfficiencyPct ));
179
+ // Guarded here (not just at the CLI/mcp-tools call sites) so any caller of
180
+ // evaluateBudgets() gets this for free: regressionMetric with no
181
+ // maxRegressionPct would otherwise skip the whole `if` below silently,
182
+ // reporting nothing at all instead of a visible inconclusive result.
183
+ if (budgets.regressionMetric !== undefined && budgets.maxRegressionPct === undefined) {
184
+ results.push({ name: 'max-regression', status: 'inconclusive', detail: `regressionMetric "${budgets.regressionMetric}" was set without maxRegressionPct; the regression budget was not evaluated.` });
185
+ } else if (budgets.maxRegressionPct !== undefined) {
186
+ // `!== undefined`, not `Number.isFinite`: a zero-baseline regression's pct
187
+ // is itself `Infinity` (see checkRegression), so an "unlimited" budget is a
188
+ // legitimate finite-typed-as-number input here (CLI's own flag validation
189
+ // already rejects non-finite --max-regression-pct input, so this only
190
+ // widens what a direct evaluateBudgets caller, e.g. a test, can express).
191
+ pushComparisonBudget(results, comparison, 'max-regression',
192
+ (c) => checkRegression(c, budgets.maxRegressionPct , budgets.regressionMetric ?? 'wallClock'));
193
+ }
194
+ if (budgets.failOnIntroduced !== undefined) {
195
+ pushComparisonBudget(results, comparison, 'fail-on-introduced',
196
+ (c) => checkFailOnIntroduced(c, budgets.failOnIntroduced ));
197
+ }
198
+ return {
199
+ results,
200
+ violated: results.some((r) => r.status === 'violation'),
201
+ inconclusive: results.some((r) => r.status === 'inconclusive'),
202
+ };
203
+ }
@@ -0,0 +1,107 @@
1
+ import { readFileSync, readdirSync, statSync } from 'node:fs';
2
+ import { join, basename } from 'node:path';
3
+ import { createState, runParse, runParseFiles, reassembleRollingEntries } from '../parser-worker.js';
4
+ import { createModelCallbacks } from '../model-assembler.js';
5
+ import { routeMessage, } from '../ingest.js';
6
+
7
+
8
+
9
+
10
+
11
+
12
+
13
+
14
+ export function emptyAppModel() {
15
+ return {
16
+ app: null,
17
+ stages: new Map(),
18
+ executors: { added: [], removed: [] },
19
+ sql: new Map(),
20
+ jobs: new Map(),
21
+ runAggregates: null,
22
+ evidenceAvailability: null,
23
+ };
24
+ }
25
+
26
+ // Mirrors tests/parser-worker.test.js's fakeFile: the proven File-like shape
27
+ // runParse/runParseFiles need (name, size, slice().arrayBuffer(), arrayBuffer()).
28
+ export function nodeFileFromPath(path ) {
29
+ const bytes = readFileSync(path);
30
+ const u8 = new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength);
31
+ return {
32
+ name: basename(path),
33
+ size: u8.length,
34
+ slice(start , end ) {
35
+ const view = u8.subarray(start, end);
36
+ return { async arrayBuffer() { return view.slice().buffer; } };
37
+ },
38
+ async arrayBuffer() { return u8.slice().buffer; },
39
+ };
40
+ }
41
+
42
+ // Delegates to src/ingest.js's routeMessage: the single source of truth for
43
+ // worker-message-type -> handler-callback wiring, shared by the browser
44
+ // Worker context and this synchronous-in-Node CLI path. No pendingTaskRequests
45
+ // map: the CLI never sends a 'getTaskData' request, so it never receives a
46
+ // 'taskData' message back.
47
+ export function dispatch(msg , handlers ) {
48
+ routeMessage(msg , handlers);
49
+ }
50
+
51
+ function isRollingLogDirectory(dirPath ) {
52
+ const names = readdirSync(dirPath);
53
+ return names.some((n ) => /^events_\d+_/.test(n));
54
+ }
55
+
56
+ // Shared ingest scaffold for both consumers of routeMessage's onDone/onError
57
+ // dispatch pattern (this file's collectRun, over a local file/dir, and
58
+ // shs-load.ts's collectShsAppModel, over fetched archive bytes): builds a
59
+ // fresh AppModel wired to the shared handlers, then hands `run` a
60
+ // (state, emit, reject) triple so each caller only supplies its own decode
61
+ // step (and its own error type via `onDecodeError` — a plain Error here, an
62
+ // mcpError over in shs-load.ts).
63
+ export function collectViaDispatch(
64
+ run ,
65
+ onDecodeError ,
66
+ ) {
67
+ const appModel = emptyAppModel();
68
+ const cb = createModelCallbacks(appModel, { onProgress() {}, onDone() {}, onError() {} });
69
+
70
+ return new Promise((resolve, reject) => {
71
+ const handlers = {
72
+ ...cb,
73
+ onDone: (msg ) => resolve({ appModel, skippedLines: (msg )?.skippedLines ?? 0 }),
74
+ onError: (msg ) => reject(onDecodeError(msg)),
75
+ };
76
+ const emit = (msg ) => dispatch(msg, handlers);
77
+ const state = createState();
78
+ run(state, emit, reject);
79
+ });
80
+ }
81
+
82
+ export async function collectRun(inputPath ) {
83
+ const stat = statSync(inputPath);
84
+ return collectViaDispatch((state, emit, reject) => {
85
+ if (stat.isDirectory()) {
86
+ if (!isRollingLogDirectory(inputPath)) {
87
+ reject(new Error("This isn't a Spark rolling event-log directory. Pass a single event-log file instead."));
88
+ return;
89
+ }
90
+ const names = readdirSync(inputPath);
91
+ let ordered;
92
+ try {
93
+ ordered = reassembleRollingEntries(names);
94
+ } catch (e) {
95
+ reject(e);
96
+ return;
97
+ }
98
+ const files = ordered.map((name) => nodeFileFromPath(join(inputPath, name)));
99
+ // .catch(reject), not void: an unexpected throw past the parser's own
100
+ // guards (e.g. in decoder.flush) would otherwise leave this Promise
101
+ // pending forever and surface only as an unhandled rejection.
102
+ runParseFiles(files, state, { emit }).catch(reject);
103
+ } else {
104
+ runParse(nodeFileFromPath(inputPath), state, { emit }).catch(reject);
105
+ }
106
+ }, (msg) => new Error((msg ).message));
107
+ }
@@ -0,0 +1,68 @@
1
+ // Total cores computation: sum real executor totalCores, or fallback to
2
+ // peakExecutors × configured cores. Used by efficiency model, scaling simulator,
3
+ // wasted core-hours calculation, and utilization/memory-utilization detectors.
4
+ export function computeTotalCores(app , executorsAdded ) {
5
+ let total = executorsAdded.reduce((s, e) => s + (e.totalCores ?? 0), 0);
6
+ if (total <= 0) {
7
+ const cores = app.resources?.executor?.cores ?? null;
8
+ total = cores != null ? executorsAdded.length * cores : 0;
9
+ }
10
+ return total;
11
+ }
12
+
13
+ // Peak concurrently-alive core count: sweeps add/remove events by timestamp
14
+ // (add contributes +totalCores at its own executorId, remove contributes
15
+ // -totalCores looked up from that same executorId) and tracks the running
16
+ // total's max, rather than summing every addition regardless of overlap.
17
+ // computeTotalCores's cumulative sum overstates capacity under dynamic
18
+ // allocation/executor replacement, since a churned-through executor's cores
19
+ // are never actually concurrent with its replacement's; this is the
20
+ // concurrency-aware sibling used to bound the impact estimator's ceiling
21
+ // (src/occupancy.ts's computeCeiling) against that inflation.
22
+ // Tie-break same-timestamp events by delta ascending, so a removal is applied
23
+ // before a same-instant replacement's addition: without this, a seamless swap
24
+ // (old executor gone exactly when its replacement joins) would momentarily
25
+ // double-count both as concurrent.
26
+ function sweepPeak(events ) {
27
+ const sorted = [...events].sort((a, b) => a.time - b.time || a.delta - b.delta);
28
+ let running = 0;
29
+ let peak = 0;
30
+ for (const ev of sorted) {
31
+ running += ev.delta;
32
+ if (running > peak) peak = running;
33
+ }
34
+ return peak;
35
+ }
36
+
37
+ export function computePeakConcurrentCores(
38
+ app ,
39
+ executorsAdded ,
40
+ executorsRemoved ,
41
+ ) {
42
+ const coresByExecutor = new Map ();
43
+ const coreEvents = [];
44
+ const countEvents = [];
45
+ for (const e of executorsAdded) {
46
+ const cores = e.totalCores ?? 0;
47
+ coresByExecutor.set(e.executorId, cores);
48
+ coreEvents.push({ time: e.timestamp, delta: cores });
49
+ countEvents.push({ time: e.timestamp, delta: 1 });
50
+ }
51
+ for (const e of executorsRemoved) {
52
+ const cores = coresByExecutor.get(e.executorId) ?? 0;
53
+ coreEvents.push({ time: e.timestamp, delta: -cores });
54
+ countEvents.push({ time: e.timestamp, delta: -1 });
55
+ }
56
+ const peak = sweepPeak(coreEvents);
57
+ if (peak > 0) return peak;
58
+ // Real totalCores data is missing/zero for every add event, so the cores
59
+ // sweep above can't tell us anything. Falling back to
60
+ // `executorsAdded.length * cores` here would reintroduce the exact
61
+ // cumulative-historical-additions overcount this function exists to avoid
62
+ // (a churned-through executor and its replacement both counted, even
63
+ // though they were never alive at once). Sweep peak *executor count*
64
+ // instead: still concurrency-aware, just cores-blind.
65
+ const peakExecutorCount = sweepPeak(countEvents);
66
+ const cores = app.resources?.executor?.cores ?? null;
67
+ return cores != null ? peakExecutorCount * cores : 0;
68
+ }
@@ -0,0 +1,54 @@
1
+ // Whole-run core-usage-locality ratio: non-local task share across every
2
+ // stage's `stage.localityStats`. Mirrors wasted-core-hours.js's shape (pure
3
+ // reducer, no detector or view coupling), importable by both the
4
+ // `coreLocality` detector (src/detectors.js) and CoreUsageArea.tsx's
5
+ // stage-breakdown list.
6
+ //
7
+ // Locality classification (Spark, best to worst): PROCESS_LOCAL > NODE_LOCAL
8
+ // > NO_PREF > RACK_LOCAL > ANY. NO_PREF is not a locality failure; it's what
9
+ // shuffle-read stages report because there's no location-preference concept
10
+ // for a shuffle fetch, so it stays in the denominator (diluting the ratio for
11
+ // shuffle-heavy stages, which is correct) but never in the numerator.
12
+ const NON_LOCAL_TIERS = new Set(['RACK_LOCAL', 'ANY']);
13
+ const TOP_N = 5;
14
+ const MIN_TASKS_PER_STAGE = 10;
15
+
16
+ const EMPTY = { totalTasks: null, nonLocalTasks: null, ratio: null, topStages: [] };
17
+
18
+ export function computeCoreLocalityRatio(stages , { minTasksPerStage = MIN_TASKS_PER_STAGE, topN = TOP_N } = {}) {
19
+ if (!Array.isArray(stages) || stages.length === 0) return EMPTY;
20
+
21
+ let totalTasks = 0;
22
+ let nonLocalTasks = 0;
23
+ const perStage = [];
24
+
25
+ for (const stage of stages) {
26
+ const localityStats = stage?.localityStats;
27
+ if (!Array.isArray(localityStats) || localityStats.length === 0) continue;
28
+
29
+ let stageTotal = 0;
30
+ let stageNonLocal = 0;
31
+ for (const { locality, count } of localityStats) {
32
+ if (!Number.isFinite(count)) continue;
33
+ stageTotal += count;
34
+ if (NON_LOCAL_TIERS.has(locality)) stageNonLocal += count;
35
+ }
36
+ totalTasks += stageTotal;
37
+ nonLocalTasks += stageNonLocal;
38
+
39
+ if (stageTotal >= minTasksPerStage) {
40
+ perStage.push({
41
+ stageId: stage.id,
42
+ nonLocalTasks: stageNonLocal,
43
+ taskCount: stageTotal,
44
+ ratio: stageNonLocal / stageTotal,
45
+ });
46
+ }
47
+ }
48
+
49
+ if (totalTasks === 0) return EMPTY;
50
+
51
+ const topStages = perStage.sort((a, b) => b.nonLocalTasks - a.nonLocalTasks).slice(0, topN);
52
+
53
+ return { totalTasks, nonLocalTasks, ratio: nonLocalTasks / totalTasks, topStages };
54
+ }
@@ -0,0 +1,92 @@
1
+ // Sweep-line "busy cores over time" computation. Pure and side-effect-free so
2
+ // it can run inside parser-worker.js (over retained task launch/finish
3
+ // timestamps) or on the main thread (scaling simulator). One core per task:
4
+ // Spark's default task-to-core mapping.
5
+ //
6
+ // `hypotheticalCores` clamps the concurrent busy-core count to N (the
7
+ // deterministic "utilization at N cores" curve). It does NOT re-schedule tasks;
8
+ // makespan re-estimation is the scaling simulator's job, layered on this signal.
9
+
10
+
11
+
12
+
13
+
14
+
15
+
16
+
17
+
18
+
19
+
20
+
21
+ function buildStepFunction(intervals , hypotheticalCores ) {
22
+ // Emit +1 at launch, -1 at finish. Skip empty/negative intervals.
23
+ const events = [];
24
+ for (const { launch, finish } of intervals) {
25
+ if (!(finish > launch)) continue;
26
+ events.push({ t: launch, delta: 1 });
27
+ events.push({ t: finish, delta: -1 });
28
+ }
29
+ // Sort by time; at equal time process -1 before +1 so [launch, finish) is
30
+ // half-open (a task finishing exactly as another launches does not overlap).
31
+ events.sort((a, b) => (a.t - b.t) || (a.delta - b.delta));
32
+
33
+ // Walk consecutive timestamps, emitting the busy level held on each interval.
34
+ const segments = [];
35
+ let busy = 0;
36
+ for (let i = 0; i < events.length; i++) {
37
+ const cur = events[i];
38
+ busy += cur.delta;
39
+ const next = events[i + 1];
40
+ if (!next || next.t === cur.t) continue;
41
+ const effective = hypotheticalCores != null ? Math.min(busy, hypotheticalCores) : busy;
42
+ segments.push({ tStart: cur.t, tEnd: next.t, busy: effective });
43
+ }
44
+ return segments;
45
+ }
46
+
47
+ export function computeCoreTimeSeries(intervals , {
48
+ bucketBy = 'time',
49
+ bucketWidthMs = 1000,
50
+ hypotheticalCores = null,
51
+ }
52
+
53
+
54
+
55
+ = {})
56
+
57
+
58
+
59
+
60
+
61
+ {
62
+ const segments = buildStepFunction(intervals, hypotheticalCores);
63
+
64
+ if (bucketBy === 'coreCount') {
65
+ const histogram = [];
66
+ for (const seg of segments) {
67
+ histogram[seg.busy] = (histogram[seg.busy] ?? 0) + (seg.tEnd - seg.tStart);
68
+ }
69
+ // Fill sparse holes with 0 so the array reads as a dense histogram.
70
+ for (let k = 0; k < histogram.length; k++) if (histogram[k] === undefined) histogram[k] = 0;
71
+ return { mode: 'coreCount', histogram };
72
+ }
73
+
74
+ // bucketBy === 'time'
75
+ if (segments.length === 0) {
76
+ return { mode: 'time', bucketWidthMs, startTime: null, endTime: null, buckets: [] };
77
+ }
78
+ const startTime = segments[0].tStart;
79
+ const endTime = segments[segments.length - 1].tEnd;
80
+ const buckets = [];
81
+ for (let tStart = startTime; tStart < endTime; tStart += bucketWidthMs) {
82
+ const tEnd = tStart + bucketWidthMs;
83
+ let busyCoreMs = 0;
84
+ for (const seg of segments) {
85
+ const lo = Math.max(seg.tStart, tStart);
86
+ const hi = Math.min(seg.tEnd, tEnd);
87
+ if (hi > lo) busyCoreMs += seg.busy * (hi - lo);
88
+ }
89
+ buckets.push({ tStart, tEnd, busyCoreMs, avgBusyCores: busyCoreMs / bucketWidthMs });
90
+ }
91
+ return { mode: 'time', bucketWidthMs, startTime, endTime, buckets };
92
+ }
@@ -0,0 +1,70 @@
1
+ // Stage-granular approximation of core-usage-by-locality over time. See the
2
+ // plan's Task 6 DEVIATION note: per-task locality is not retained, so this
3
+ // distributes each stage's executorRunTime across its wall-clock window,
4
+ // split by localityStats proportions. Not exact; approximate by construction.
5
+ export const LOCALITY_TIERS = ['PROCESS_LOCAL', 'NODE_LOCAL', 'RACK_LOCAL', 'NO_PREF', 'ANY'];
6
+
7
+
8
+
9
+
10
+
11
+
12
+
13
+
14
+
15
+
16
+
17
+
18
+
19
+
20
+
21
+
22
+
23
+
24
+ export function computeLocalityAreaSeries(
25
+ stages ,
26
+ { bucketWidthMs = 60_000, tiers = LOCALITY_TIERS } = {},
27
+ ) {
28
+ const valid = stages.filter(s => (s.completedAt ?? 0) > (s.submittedAt ?? 0) && (s.executorRunTime ?? 0) > 0);
29
+ if (valid.length === 0) return { labels: [], series: {} };
30
+ const startTime = valid.reduce((m, s) => Math.min(m, s.submittedAt ?? m), Infinity);
31
+ const endTime = valid.reduce((m, s) => Math.max(m, s.completedAt ?? m), -Infinity);
32
+ const nBuckets = Math.max(1, Math.ceil((endTime - startTime) / bucketWidthMs));
33
+
34
+ const series = {};
35
+ for (const tier of tiers) series[tier] = new Array(nBuckets).fill(0);
36
+ const other = new Array(nBuckets).fill(0); // localities not in the fixed tier list
37
+
38
+ for (const s of valid) {
39
+ const completedAt = s.completedAt ?? 0;
40
+ const submittedAt = s.submittedAt ?? 0;
41
+ const wall = completedAt - submittedAt;
42
+ const avgCores = (s.executorRunTime ?? 0) / wall; // core-time / wall-time = avg concurrent cores
43
+ const total = (s.localityStats ?? []).reduce((a, l) => a + l.count, 0) || 1;
44
+ const props = new Map((s.localityStats ?? []).map(l => [l.locality, l.count / total]));
45
+ // spread avgCores over the buckets this stage overlaps, weighted by overlap fraction
46
+ for (let b = 0; b < nBuckets; b++) {
47
+ const bStart = startTime + b * bucketWidthMs;
48
+ const bEnd = bStart + bucketWidthMs;
49
+ const overlap = Math.min(completedAt, bEnd) - Math.max(submittedAt, bStart);
50
+ if (overlap <= 0) continue;
51
+ const frac = overlap / bucketWidthMs; // portion of the bucket this stage covers
52
+ for (const [loc, p] of props) {
53
+ const add = avgCores * p * frac;
54
+ if (series[loc]) series[loc][b] += add;
55
+ else other[b] += add;
56
+ }
57
+ }
58
+ }
59
+ if (other.some(v => v > 0)) series.OTHER = other;
60
+
61
+ // idle = peak total busy across buckets, minus each bucket's total busy.
62
+ const totals = new Array(nBuckets).fill(0);
63
+ for (const tier of Object.keys(series)) for (let b = 0; b < nBuckets; b++) totals[b] += series[tier][b];
64
+ const peak = totals.reduce((m, v) => Math.max(m, v), 0);
65
+ series.idle = totals.map(v => Math.max(0, peak - v));
66
+
67
+ const labels = [];
68
+ for (let b = 0; b < nBuckets; b++) labels.push(startTime + b * bucketWidthMs);
69
+ return { labels, series };
70
+ }