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,458 @@
|
|
|
1
|
+
// src/run-comparison.ts
|
|
2
|
+
import { computeWallClock } from './wall-clock.js';
|
|
3
|
+
import { normalizeDetail } from './detectors.js';
|
|
4
|
+
import { captureSnapshot } from './session-snapshot.js';
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
// Replace run-varying tokens (digit runs, long hex ids) with a stable marker so
|
|
29
|
+
// the same logical stage across two runs normalizes to one identity.
|
|
30
|
+
export function normalizeStageName(name ) {
|
|
31
|
+
return String(name)
|
|
32
|
+
.toLowerCase()
|
|
33
|
+
.replace(/\b[0-9a-f]{8,}\b/g, '#') // hex ids/uuids first (they contain digits)
|
|
34
|
+
.replace(/\d+/g, '#')
|
|
35
|
+
.replace(/\s+/g, ' ')
|
|
36
|
+
.trim();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// cyrb53: fast, non-cryptographic 53-bit string hash, deterministic, portable
|
|
40
|
+
// (runs in-browser worker and Node CLI, no crypto module dependency), fixed-
|
|
41
|
+
// length hex output regardless of input size. src/analyzer.js already has a
|
|
42
|
+
// small stable-hash helper (`fnv1a`, 32-bit) for deterministic finding ids,
|
|
43
|
+
// but that runs once per finding; planTreeIdentity's `visit` below runs once
|
|
44
|
+
// per PLAN NODE, for every stage, in both runs of a comparison: orders of
|
|
45
|
+
// magnitude more hash calls, so the smaller 32-bit space's birthday-collision
|
|
46
|
+
// risk is a real concern here in a way it isn't for finding ids. 53 bits keeps
|
|
47
|
+
// collision probability negligible at that scale; adequate for a same-run
|
|
48
|
+
// identity key, not a security boundary.
|
|
49
|
+
function cyrb53(str , seed = 0) {
|
|
50
|
+
let h1 = 0xdeadbeef ^ seed, h2 = 0x41c6ce57 ^ seed;
|
|
51
|
+
for (let i = 0; i < str.length; i++) {
|
|
52
|
+
const ch = str.charCodeAt(i);
|
|
53
|
+
h1 = Math.imul(h1 ^ ch, 2654435761);
|
|
54
|
+
h2 = Math.imul(h2 ^ ch, 1597334677);
|
|
55
|
+
}
|
|
56
|
+
h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909);
|
|
57
|
+
h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909);
|
|
58
|
+
return (4294967296 * (2097151 & h2) + (h1 >>> 0)).toString(16);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Bottom-up, order-independent structural identity of a resolved plan tree:
|
|
62
|
+
// each node folds its own normalized name and normalized `detail` with its
|
|
63
|
+
// already-computed children's DIGESTS (children sorted, so sibling
|
|
64
|
+
// reordering across runs (e.g. AQE picking a different broadcast side)
|
|
65
|
+
// still matches), so two plans collide only when their whole shape AND every
|
|
66
|
+
// node's normalized detail agree. `normalizeDetail` (src/detectors.js, built
|
|
67
|
+
// for cachingOpportunity's composite join/union detection) strips expr ids,
|
|
68
|
+
// `plan_id=`, codegen-stage numbers, and AQE's BuildLeft/BuildRight choice,
|
|
69
|
+
// and canonicalizes commutative equality operands: exactly the run-to-run
|
|
70
|
+
// noise a cross-run identity needs to ignore. Replaces the previous
|
|
71
|
+
// flatten-and-sort-names identity, which ignored `detail` entirely (so a
|
|
72
|
+
// join on one column and a join on another, with identical operator names,
|
|
73
|
+
// collided) and ignored nesting (so two differently-shaped trees sharing a
|
|
74
|
+
// node-name multiset also collided).
|
|
75
|
+
//
|
|
76
|
+
// Each node is encoded via JSON.stringify of its [name, detail, childDigests]
|
|
77
|
+
// triple rather than an ad hoc delimiter-joined string: real Spark `detail`
|
|
78
|
+
// text routinely contains its own `<`/`>`/`{`/`}`/`,` characters (e.g.
|
|
79
|
+
// `ReadSchema: struct<id:bigint>`), so a hand-rolled delimiter scheme could
|
|
80
|
+
// let two structurally different plans serialize to the same string;
|
|
81
|
+
// JSON.stringify escapes/quotes each component so the encoding is
|
|
82
|
+
// unambiguous. Critically, that JSON payload is then folded down to a fixed-
|
|
83
|
+
// length `cyrb53` digest before being returned: the payload only ever
|
|
84
|
+
// contains a node's own (short) name/detail plus an array of already-hashed,
|
|
85
|
+
// fixed-length child digests, never a raw nested JSON blob. Embedding full
|
|
86
|
+
// child identity STRINGS instead (this function's first version) made each
|
|
87
|
+
// level re-stringify-and-escape the level below, so identity length grew
|
|
88
|
+
// ~2^depth; real Spark plans routinely nest 20-60+ operators deep
|
|
89
|
+
// (Scan→Filter→Project→HashAggregate→Exchange→Sort→Join, repeated per
|
|
90
|
+
// join/aggregate), which blew up to megabytes-per-stage or a
|
|
91
|
+
// `RangeError: Invalid string length` well within that range. Digest-folding
|
|
92
|
+
// keeps per-node output size bounded by name+detail length plus a small
|
|
93
|
+
// constant per child, independent of subtree depth.
|
|
94
|
+
function planTreeIdentity(root ) {
|
|
95
|
+
if (!root) return null;
|
|
96
|
+
function visit(node ) {
|
|
97
|
+
const childDigests = (node.children ?? []).map(visit).sort();
|
|
98
|
+
return cyrb53(JSON.stringify([normalizeStageName(node.name ?? ''), normalizeDetail(node.detail ?? ''), childDigests]));
|
|
99
|
+
}
|
|
100
|
+
return visit(root);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Plan identity for the stage's SQL execution, so stage identity also requires
|
|
104
|
+
// the SQL-node identity to agree. Empty string when no SQL/plan.
|
|
105
|
+
function sqlNodeIdentity(stage , snapshot ) {
|
|
106
|
+
const execId = stage.sqlExecutionId;
|
|
107
|
+
if (execId == null) return '';
|
|
108
|
+
return planTreeIdentity(snapshot.sql.get(execId)?.planTree ?? null) ?? '';
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function stageIdentity(stage , snapshot ) {
|
|
112
|
+
return normalizeStageName(stage.name ?? '') + '§' + sqlNodeIdentity(stage, snapshot);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function identityIndex(snapshot ) {
|
|
116
|
+
const byIdentity = new Map (); // identity -> stageId[]
|
|
117
|
+
for (const [id, stage] of snapshot.stages) {
|
|
118
|
+
const key = stageIdentity(stage, snapshot);
|
|
119
|
+
const ids = byIdentity.get(key);
|
|
120
|
+
if (ids) ids.push(id);
|
|
121
|
+
else byIdentity.set(key, [id]);
|
|
122
|
+
}
|
|
123
|
+
return byIdentity;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function matchStages(baseSnap , candSnap )
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
{
|
|
132
|
+
const baseIdx = identityIndex(baseSnap);
|
|
133
|
+
const candIdx = identityIndex(candSnap);
|
|
134
|
+
const pairs = [];
|
|
135
|
+
const matchedIdentities = new Set ();
|
|
136
|
+
const collisionIdentities = new Set ();
|
|
137
|
+
// A collision is a single-run property: any identity that more than one stage
|
|
138
|
+
// in the SAME run normalizes to. Record them per-run, independent of whether
|
|
139
|
+
// the other run shares that identity (a colliding stage with no counterpart
|
|
140
|
+
// still makes the run ambiguous).
|
|
141
|
+
for (const idx of [baseIdx, candIdx])
|
|
142
|
+
for (const [identity, ids] of idx) if (ids.length > 1) collisionIdentities.add(identity);
|
|
143
|
+
// Pair only identities that map to exactly one stage on BOTH sides.
|
|
144
|
+
// Deterministic order: sort identities lexically.
|
|
145
|
+
for (const identity of [...baseIdx.keys()].sort()) {
|
|
146
|
+
if (!candIdx.has(identity)) continue;
|
|
147
|
+
const baseIds = baseIdx.get(identity) ;
|
|
148
|
+
const candIds = candIdx.get(identity) ;
|
|
149
|
+
if (baseIds.length > 1 || candIds.length > 1) continue; // collision already recorded above
|
|
150
|
+
pairs.push({ identity, baseId: baseIds[0], candId: candIds[0] });
|
|
151
|
+
matchedIdentities.add(identity);
|
|
152
|
+
}
|
|
153
|
+
const total = baseSnap.stages.size + candSnap.stages.size;
|
|
154
|
+
const coverage = total === 0 ? 0 : (2 * pairs.length) / total;
|
|
155
|
+
return { pairs, matchedIdentities, collisionIdentities, coverage };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function p95(values ) {
|
|
159
|
+
if (values.length === 0) return null;
|
|
160
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
161
|
+
const rank = Math.ceil(0.95 * sorted.length) - 1; // nearest-rank, deterministic
|
|
162
|
+
return sorted[Math.min(sorted.length - 1, Math.max(0, rank))];
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// The stage numeric fields sumField/perStageMetrics read; keeps the generic
|
|
166
|
+
// `field` parameter narrowed to a known numeric Stage property instead of
|
|
167
|
+
// widening to `string` (Stage also carries a `[key: string]: unknown` index
|
|
168
|
+
// signature for detector-only fields).
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
// Sum a numeric stage field over a stage list; `present` is false when no stage
|
|
174
|
+
// carried a finite value (so the metric renders Unavailable rather than a false 0).
|
|
175
|
+
function sumField(stages , field ) {
|
|
176
|
+
let sum = 0, present = false;
|
|
177
|
+
for (const s of stages) {
|
|
178
|
+
const v = s[field];
|
|
179
|
+
if (Number.isFinite(v)) { sum += v ; present = true; }
|
|
180
|
+
}
|
|
181
|
+
return { sum, present };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Shared by skewRatios (whole-run p95 input) and stageSkewDeltas' `ratio`
|
|
185
|
+
// closure (per-pair matched comparison): both need the identical per-stage
|
|
186
|
+
// task-skew formula (max task duration over stage wall-clock duration).
|
|
187
|
+
function stageSkewRatio(stage ) {
|
|
188
|
+
const dur = (stage.completedAt ?? 0) - (stage.submittedAt ?? 0);
|
|
189
|
+
return dur > 0 && Number.isFinite(stage.taskDurationMax) ? (stage.taskDurationMax ) / dur : null;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function skewRatios(stages ) {
|
|
193
|
+
const out = [];
|
|
194
|
+
for (const s of stages) {
|
|
195
|
+
const ratio = stageSkewRatio(s);
|
|
196
|
+
if (ratio != null) out.push(ratio);
|
|
197
|
+
}
|
|
198
|
+
return out;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Volume/count metrics, not cost metrics: more or less input/output data, or
|
|
202
|
+
// tasks/executors, isn't inherently better or worse (it may just reflect a
|
|
203
|
+
// differently-sized job), unlike wall-clock, spill, GC, etc. Exported as the
|
|
204
|
+
// single source of truth for "which metrics have no better/worse direction",
|
|
205
|
+
// shared by the view (RunComparison.tsx, for Δ coloring) and by
|
|
206
|
+
// src/cli/budgets.ts's checkRegression (so a `--regression-metric inputBytes`
|
|
207
|
+
// budget can't misread "processed more data" as a regression).
|
|
208
|
+
export const NEUTRAL_METRIC_KEYS = new Set(['inputBytes', 'outputBytes', 'taskCount', 'executorsAdded']);
|
|
209
|
+
|
|
210
|
+
function direction(key , baseline , candidate ) {
|
|
211
|
+
if (baseline == null || candidate == null) return 'unavailable';
|
|
212
|
+
if (NEUTRAL_METRIC_KEYS.has(key)) return candidate === baseline ? 'unchanged' : 'neutral';
|
|
213
|
+
const d = candidate - baseline;
|
|
214
|
+
return d < 0 ? 'improvement' : d > 0 ? 'regression' : 'unchanged';
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function metric(
|
|
218
|
+
key , label , baseline , candidate ,
|
|
219
|
+
extra = {},
|
|
220
|
+
) {
|
|
221
|
+
const dir = direction(key, baseline, candidate);
|
|
222
|
+
const delta = baseline == null || candidate == null ? null : candidate - baseline;
|
|
223
|
+
return { key, label, baseline, candidate, delta, direction: dir, ...extra };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export function metricDeltas(baseSnap , candSnap ) {
|
|
227
|
+
const out = [];
|
|
228
|
+
|
|
229
|
+
// Wall-clock: always computable (computeWallClock tolerates a null app).
|
|
230
|
+
out.push(metric('wallClock', 'Wall-clock duration',
|
|
231
|
+
computeWallClock(baseSnap.app, baseSnap.stages).total,
|
|
232
|
+
computeWallClock(candSnap.app, candSnap.stages).total));
|
|
233
|
+
|
|
234
|
+
// Shuffle spill over the whole run: a sum needs no stage matching, and
|
|
235
|
+
// matching is unreliable on real logs (see plan rationale), so scope it to
|
|
236
|
+
// all stages exactly like task-skew and failed-rate below.
|
|
237
|
+
const bSpill = sumField([...baseSnap.stages.values()], 'memoryBytesSpilled');
|
|
238
|
+
const cSpill = sumField([...candSnap.stages.values()], 'memoryBytesSpilled');
|
|
239
|
+
out.push(metric('shuffleSpill', 'Shuffle spill',
|
|
240
|
+
bSpill.present ? bSpill.sum : null, cSpill.present ? cSpill.sum : null,
|
|
241
|
+
{ unavailableReason: bSpill.present && cSpill.present ? undefined : 'No shuffle-spill data recorded for a run' }));
|
|
242
|
+
|
|
243
|
+
// Task skew: p95 of per-stage ratio across the whole run.
|
|
244
|
+
const bSkew = p95(skewRatios([...baseSnap.stages.values()]));
|
|
245
|
+
const cSkew = p95(skewRatios([...candSnap.stages.values()]));
|
|
246
|
+
out.push(metric('taskSkew', 'Task skew (p95)', bSkew, cSkew,
|
|
247
|
+
{ unavailableReason: bSkew != null && cSkew != null ? undefined : 'No stage had measurable duration for a run' }));
|
|
248
|
+
|
|
249
|
+
// Failed-task rate: Σ failedTasks / Σ taskCount.
|
|
250
|
+
const bTasks = sumField([...baseSnap.stages.values()], 'taskCount');
|
|
251
|
+
const cTasks = sumField([...candSnap.stages.values()], 'taskCount');
|
|
252
|
+
const bFailed = sumField([...baseSnap.stages.values()], 'failedTasks');
|
|
253
|
+
const cFailed = sumField([...candSnap.stages.values()], 'failedTasks');
|
|
254
|
+
// Guard on BOTH inputs: a missing `failedTasks` field must render Unavailable,
|
|
255
|
+
// not a false 0% rate (dividing an absent-and-therefore-0 numerator).
|
|
256
|
+
const bRate = bTasks.present && bTasks.sum > 0 && bFailed.present ? bFailed.sum / bTasks.sum : null;
|
|
257
|
+
const cRate = cTasks.present && cTasks.sum > 0 && cFailed.present ? cFailed.sum / cTasks.sum : null;
|
|
258
|
+
out.push(metric('failedTaskRate', 'Failed-task rate', bRate, cRate,
|
|
259
|
+
{ unavailableReason: bRate != null && cRate != null ? undefined : 'No task or failure counts recorded for a run' }));
|
|
260
|
+
|
|
261
|
+
// Additional whole-run aggregates: plain sums of fields the stage already
|
|
262
|
+
// carries (set in finalizeStage). Correct at any match coverage, like the
|
|
263
|
+
// sums above; no parser or detector change.
|
|
264
|
+
const sumMetric = (key , label , field , reason ) => {
|
|
265
|
+
const b = sumField([...baseSnap.stages.values()], field);
|
|
266
|
+
const c = sumField([...candSnap.stages.values()], field);
|
|
267
|
+
out.push(metric(key, label, b.present ? b.sum : null, c.present ? c.sum : null,
|
|
268
|
+
{ unavailableReason: b.present && c.present ? undefined : reason }));
|
|
269
|
+
};
|
|
270
|
+
sumMetric('diskSpill', 'Disk spill', 'diskBytesSpilled', 'No disk-spill data recorded for a run');
|
|
271
|
+
sumMetric('gcTime', 'GC time', 'jvmGCTime', 'No GC-time data recorded for a run');
|
|
272
|
+
sumMetric('inputBytes', 'Input read', 'inputBytes', 'No input-bytes data recorded for a run');
|
|
273
|
+
sumMetric('outputBytes', 'Output written', 'outputBytes', 'No output-bytes data recorded for a run');
|
|
274
|
+
sumMetric('executorRunTime', 'Executor run-time', 'executorRunTime', 'No executor run-time recorded for a run');
|
|
275
|
+
sumMetric('taskCount', 'Task count', 'taskCount', 'No task counts recorded for a run');
|
|
276
|
+
|
|
277
|
+
// Executor count is app-level, not per-stage. `executors` is absent on
|
|
278
|
+
// hand-built snapshots; guard so it renders Unavailable, not a crash.
|
|
279
|
+
const execCount = (snap ) =>
|
|
280
|
+
(Array.isArray(snap.executors?.added) ? snap.executors.added.length : null);
|
|
281
|
+
const bExec = execCount(baseSnap), cExec = execCount(candSnap);
|
|
282
|
+
out.push(metric('executorsAdded', 'Executors added', bExec, cExec,
|
|
283
|
+
{ unavailableReason: bExec != null && cExec != null ? undefined : 'No executor events recorded for a run' }));
|
|
284
|
+
|
|
285
|
+
return out;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Finding categories are counted, not matched: a (rule × impact band) tally needs
|
|
289
|
+
// no cross-run stage identity, so it stays correct even when almost no stages
|
|
290
|
+
// match uniquely (the common case on real logs, see plan rationale). A
|
|
291
|
+
// category the candidate has more of is "introduced"; fewer, "resolved".
|
|
292
|
+
export function findingsDelta(baseSnap , candSnap )
|
|
293
|
+
|
|
294
|
+
{
|
|
295
|
+
|
|
296
|
+
const tally = (snap ) => {
|
|
297
|
+
const m = new Map (); // `${rule}§${impactBand}` -> { rule, impactBand, count, stages:Set }
|
|
298
|
+
for (const f of snap.catalog) {
|
|
299
|
+
const rule = typeof f.rule === 'string' ? f.rule : f.type;
|
|
300
|
+
const impactBand = f.impactBand ?? 'unknown';
|
|
301
|
+
const key = `${rule}§${impactBand}`;
|
|
302
|
+
const e = m.get(key) ?? { rule, impactBand, count: 0, stages: new Set () };
|
|
303
|
+
e.count++;
|
|
304
|
+
// Resolve stageId → name on this snapshot only: a single-side lookup, so
|
|
305
|
+
// it needs no cross-run identity. App-level findings (stageId null) add none.
|
|
306
|
+
const stage = f.stageId != null ? snap.stages.get(f.stageId) : null;
|
|
307
|
+
if (stage?.name) e.stages.add(stage.name);
|
|
308
|
+
m.set(key, e);
|
|
309
|
+
}
|
|
310
|
+
return m;
|
|
311
|
+
};
|
|
312
|
+
const b = tally(baseSnap), c = tally(candSnap);
|
|
313
|
+
const introduced = [], resolved = [];
|
|
314
|
+
for (const key of [...new Set([...b.keys(), ...c.keys()])].sort()) {
|
|
315
|
+
const be = b.get(key), ce = c.get(key);
|
|
316
|
+
const baseCount = be?.count ?? 0;
|
|
317
|
+
const candCount = ce?.count ?? 0;
|
|
318
|
+
if (candCount === baseCount) continue;
|
|
319
|
+
const meta = ce ?? be ;
|
|
320
|
+
const more = candCount > baseCount ? ce : be ; // the side with more supplies the labels
|
|
321
|
+
const row = { rule: meta.rule, impactBand: meta.impactBand, baseCount, candCount,
|
|
322
|
+
delta: candCount - baseCount, stages: [...more.stages].sort() };
|
|
323
|
+
(candCount > baseCount ? introduced : resolved).push(row);
|
|
324
|
+
}
|
|
325
|
+
return { introduced, resolved };
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function namesConflict(a , b ) {
|
|
329
|
+
return a?.name != null && b?.name != null && a.name !== b.name;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// A pair's skew ratio is null when the stage's duration wasn't measurable
|
|
333
|
+
// (see `ratio` below), matching CompareRunsResult['stageSkew']'s nullable
|
|
334
|
+
// baseline/candidate/delta fields.
|
|
335
|
+
function stageSkewDeltas(
|
|
336
|
+
baseSnap ,
|
|
337
|
+
candSnap ,
|
|
338
|
+
match ,
|
|
339
|
+
) {
|
|
340
|
+
return match.pairs.map((p) => {
|
|
341
|
+
const b = stageSkewRatio(baseSnap.stages.get(p.baseId) );
|
|
342
|
+
const c = stageSkewRatio(candSnap.stages.get(p.candId) );
|
|
343
|
+
return { identity: p.identity, baseline: b, candidate: c, delta: b != null && c != null ? c - b : null };
|
|
344
|
+
}).sort((x, y) => x.identity < y.identity ? -1 : x.identity > y.identity ? 1 : 0);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// Compact, view-friendly per-stage record for the manual stage-pinning panel.
|
|
348
|
+
// Every field is number | null; a null means the stage did not carry it.
|
|
349
|
+
// Exported: it's CompareRunsResult['baseStages'/'candStages']'s metrics type.
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
function perStageMetrics(stage ) {
|
|
363
|
+
const num = (v ) => (Number.isFinite(v) ? (v ) : null);
|
|
364
|
+
const dur = (stage.completedAt ?? 0) - (stage.submittedAt ?? 0);
|
|
365
|
+
return {
|
|
366
|
+
duration: dur > 0 ? dur : null,
|
|
367
|
+
memoryBytesSpilled: num(stage.memoryBytesSpilled),
|
|
368
|
+
diskBytesSpilled: num(stage.diskBytesSpilled),
|
|
369
|
+
jvmGCTime: num(stage.jvmGCTime),
|
|
370
|
+
inputBytes: num(stage.inputBytes),
|
|
371
|
+
outputBytes: num(stage.outputBytes),
|
|
372
|
+
executorRunTime: num(stage.executorRunTime),
|
|
373
|
+
taskCount: num(stage.taskCount),
|
|
374
|
+
failedTasks: num(stage.failedTasks),
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function stageList(snap ) {
|
|
379
|
+
return [...snap.stages].map(([id, s]) => ({ id, name: s.name ?? `Stage ${id}`, metrics: perStageMetrics(s) }));
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// Shared by every buildComparison caller below: captureSnapshot only ever
|
|
383
|
+
// copies this into a fresh Map internally, never mutates the caller's
|
|
384
|
+
// reference, so one shared empty Map is safe in place of a fresh allocation
|
|
385
|
+
// per call.
|
|
386
|
+
const EMPTY_TASK_DATA = new Map ();
|
|
387
|
+
|
|
388
|
+
// Wraps the analyze()-to-compareRuns() snapshot-building sequence shared by
|
|
389
|
+
// the CLI's --baseline path and mcp-tools.ts's compareRuns tool: both need
|
|
390
|
+
// captureSnapshot (with an empty taskDataCache — the interactive drill-down
|
|
391
|
+
// cache, never read by compareRuns/matchStages/metricDeltas/findingsDelta)
|
|
392
|
+
// for each side before diffing them.
|
|
393
|
+
export function buildComparison(
|
|
394
|
+
baseline ,
|
|
395
|
+
candidate ,
|
|
396
|
+
) {
|
|
397
|
+
return compareRuns(
|
|
398
|
+
{ label: baseline.label, snapshot: captureSnapshot(baseline.appModel, baseline.catalog, EMPTY_TASK_DATA) },
|
|
399
|
+
{ label: candidate.label, snapshot: captureSnapshot(candidate.appModel, candidate.catalog, EMPTY_TASK_DATA) },
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
export function compareRuns(
|
|
404
|
+
baseline ,
|
|
405
|
+
candidate ,
|
|
406
|
+
) {
|
|
407
|
+
const baseSnap = baseline.snapshot, candSnap = candidate.snapshot;
|
|
408
|
+
const match = matchStages(baseSnap, candSnap);
|
|
409
|
+
// Name equality is a weak confidence signal, not a hard gate: renaming a job
|
|
410
|
+
// is the normal way to label an A/B experiment, so a mismatch must not block
|
|
411
|
+
// the (matching-free, name-independent) deltas. Surface it as `low` instead.
|
|
412
|
+
const namesDiffer = namesConflict(baseSnap.app, candSnap.app);
|
|
413
|
+
return {
|
|
414
|
+
baselineLabel: baseline.label, candidateLabel: candidate.label,
|
|
415
|
+
confidence: namesDiffer ? 'low' : 'ok',
|
|
416
|
+
reason: namesDiffer ? 'Run names differ, so deltas may compare different work.' : null,
|
|
417
|
+
matchedCoverage: match.coverage,
|
|
418
|
+
metrics: metricDeltas(baseSnap, candSnap),
|
|
419
|
+
findings: findingsDelta(baseSnap, candSnap),
|
|
420
|
+
stageSkew: stageSkewDeltas(baseSnap, candSnap, match),
|
|
421
|
+
baseStages: stageList(baseSnap),
|
|
422
|
+
candStages: stageList(candSnap),
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// One introduced/resolved findings-delta block: `### <title> (<count>)`
|
|
427
|
+
// heading followed by one bullet per finding.
|
|
428
|
+
function renderFindingsSection(title , findings ) {
|
|
429
|
+
const lines = [`### ${title} (${findings.length})`, ''];
|
|
430
|
+
for (const f of findings) {
|
|
431
|
+
lines.push(`- [${f.impactBand}] ${f.rule}: ${f.baseCount} -> ${f.candCount}`);
|
|
432
|
+
}
|
|
433
|
+
return lines;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// Small Markdown renderer for the comparison section, matching
|
|
437
|
+
// evidence-report.ts's renderMarkdown house style (## section heading, ###
|
|
438
|
+
// subheadings, `- key: value` bullets). Shared by the CLI's --baseline
|
|
439
|
+
// markdown output and the MCP server's compare_runs `format: 'md'`.
|
|
440
|
+
export function renderComparisonMarkdown(comparison ) {
|
|
441
|
+
const lines = ['', '## Comparison to baseline', ''];
|
|
442
|
+
if (comparison.confidence === 'low') {
|
|
443
|
+
lines.push(`- confidence: low — ${comparison.reason}`);
|
|
444
|
+
lines.push('');
|
|
445
|
+
}
|
|
446
|
+
lines.push(`- matched stage coverage: ${(comparison.matchedCoverage * 100).toFixed(1)}%`);
|
|
447
|
+
lines.push('');
|
|
448
|
+
lines.push('### Metric deltas');
|
|
449
|
+
lines.push('');
|
|
450
|
+
for (const m of comparison.metrics) {
|
|
451
|
+
lines.push(`- ${m.label}: ${m.baseline ?? 'n/a'} -> ${m.candidate ?? 'n/a'} (${m.direction})`);
|
|
452
|
+
}
|
|
453
|
+
lines.push('');
|
|
454
|
+
lines.push(...renderFindingsSection('Introduced findings', comparison.findings.introduced));
|
|
455
|
+
lines.push('');
|
|
456
|
+
lines.push(...renderFindingsSection('Resolved findings', comparison.findings.resolved));
|
|
457
|
+
return lines.join('\n');
|
|
458
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// §4 What-if executor-scaling simulator (SparkLens ExecutorWallclockAnalyzer).
|
|
2
|
+
// DESIGN SPIKE: the makespan model is a first-cut approximation and is NOT
|
|
3
|
+
// validated against ground truth (a single event log only ever observes one
|
|
4
|
+
// scale). Every prediction ships with a Model Error confidence indicator.
|
|
5
|
+
//
|
|
6
|
+
// Builds on computeCoreTimeSeries' clamp conceptually, but operates on the
|
|
7
|
+
// worker-posted per-stage aggregates (runAggregates.perStage) so raw task data
|
|
8
|
+
// never reaches the main thread.
|
|
9
|
+
import { computeWallClock } from './wall-clock.js';
|
|
10
|
+
import { computeTotalCores } from './core-count.js';
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
const TEST_PERCENTAGES = [10, 20, 50, 80, 100, 110, 120, 150, 200, 300, 400, 500];
|
|
14
|
+
|
|
15
|
+
// Idealized makespan: total task-time spread over min(cores, taskCount) cores.
|
|
16
|
+
export function estimatedStageDurationAtCores(totalTaskDurationSum , taskCount , cores ) {
|
|
17
|
+
if (taskCount <= 0 || cores <= 0) return 0;
|
|
18
|
+
return totalTaskDurationSum / Math.min(cores, taskCount);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Sum estimated per-stage durations at N cores. Idealized: ignores scheduling
|
|
22
|
+
// overhead, locality, shuffle-fetch contention (all captured in the real run's
|
|
23
|
+
// actual duration at current scale, hence Model Error).
|
|
24
|
+
function estimatedTotalAtCores(perStage , cores ) {
|
|
25
|
+
let sum = 0;
|
|
26
|
+
for (const stageId of Object.keys(perStage)) {
|
|
27
|
+
const { totalTaskDurationSum, taskCount } = perStage[stageId];
|
|
28
|
+
sum += estimatedStageDurationAtCores(totalTaskDurationSum, taskCount, cores);
|
|
29
|
+
}
|
|
30
|
+
return sum;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function simulateScaling({ app, stages, runAggregates, executorsAdded }
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
{
|
|
44
|
+
const perStage = runAggregates?.perStage ?? {};
|
|
45
|
+
// `app ?? {}`: computeTotalCores just falls back to the executor-derived core sum when
|
|
46
|
+
// `resources` is absent, and every caller here already tolerates a null `app` (malformed
|
|
47
|
+
// logs with no ApplicationStart); `app!` would crash on `app.resources` in that case.
|
|
48
|
+
// `executorsAdded` cast: computeTotalCores only reads `totalCores`, present on
|
|
49
|
+
// ExecutorAddedEvent (the only kind real callers pass here) but not ExecutorRemovedEvent,
|
|
50
|
+
// so the union as a whole is a structural mismatch against computeTotalCores's
|
|
51
|
+
// `{totalCores?}` shape.
|
|
52
|
+
const baselineCores = computeTotalCores(app ?? {}, executorsAdded );
|
|
53
|
+
const wc = computeWallClock(app, stages );
|
|
54
|
+
const observedActiveMs = wc.stagesActive;
|
|
55
|
+
|
|
56
|
+
// Model Error: predicted-at-baseline vs. observed stages-active wall-clock.
|
|
57
|
+
const predictedAtBaseline = baselineCores > 0 ? estimatedTotalAtCores(perStage, baselineCores) : 0;
|
|
58
|
+
const modelErrorPct = observedActiveMs > 0
|
|
59
|
+
? Math.round(Math.abs(predictedAtBaseline - observedActiveMs) / observedActiveMs * 100)
|
|
60
|
+
: null;
|
|
61
|
+
|
|
62
|
+
// Scale factor projects the idealized sum onto the observed active wall-clock,
|
|
63
|
+
// so predictions are relative to the real run rather than the raw idealization.
|
|
64
|
+
const scale = predictedAtBaseline > 0 ? observedActiveMs / predictedAtBaseline : 1;
|
|
65
|
+
|
|
66
|
+
const predictions = TEST_PERCENTAGES.map(pct => {
|
|
67
|
+
const cores = Math.max(1, Math.round(baselineCores * pct / 100));
|
|
68
|
+
const estMakespanMs = Math.round(estimatedTotalAtCores(perStage, cores) * scale);
|
|
69
|
+
return { pct, cores, estMakespanMs };
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
return { baselineCores, testPercentages: TEST_PERCENTAGES, predictions, modelErrorPct };
|
|
73
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// Lightweight in-memory snapshot of a parsed file's view state, so switching
|
|
2
|
+
// between already-parsed files is instant without keeping multiple workers
|
|
3
|
+
// alive. Holds only the summary AppModel + catalog + prefetched task data,
|
|
4
|
+
// never the worker's full taskStore.
|
|
5
|
+
|
|
6
|
+
import { isSupportedEvidenceAvailability } from './evidence-availability.js';
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
export function captureSnapshot(
|
|
32
|
+
appModel ,
|
|
33
|
+
catalog ,
|
|
34
|
+
taskDataCache ,
|
|
35
|
+
) {
|
|
36
|
+
return {
|
|
37
|
+
app: appModel.app,
|
|
38
|
+
stages: new Map(appModel.stages),
|
|
39
|
+
executors: {
|
|
40
|
+
added: [...appModel.executors.added],
|
|
41
|
+
removed: [...appModel.executors.removed],
|
|
42
|
+
},
|
|
43
|
+
sql: new Map(appModel.sql),
|
|
44
|
+
jobs: new Map(appModel.jobs),
|
|
45
|
+
runAggregates: appModel.runAggregates,
|
|
46
|
+
evidenceAvailability: appModel.evidenceAvailability,
|
|
47
|
+
catalog: [...catalog],
|
|
48
|
+
taskData: new Map(taskDataCache),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Restores a snapshot by mutating the live `appModel` and `taskDataCache` in
|
|
53
|
+
// place, so references captured elsewhere (e.g. the stage-detail modal closure)
|
|
54
|
+
// keep pointing at the current data. Returns the restored catalog.
|
|
55
|
+
export function applySnapshot(
|
|
56
|
+
appModel ,
|
|
57
|
+
taskDataCache ,
|
|
58
|
+
snapshot ,
|
|
59
|
+
) {
|
|
60
|
+
appModel.app = snapshot.app;
|
|
61
|
+
appModel.stages = new Map(snapshot.stages);
|
|
62
|
+
appModel.executors = {
|
|
63
|
+
added: [...snapshot.executors.added] ,
|
|
64
|
+
removed: [...snapshot.executors.removed] ,
|
|
65
|
+
};
|
|
66
|
+
appModel.sql = new Map(snapshot.sql);
|
|
67
|
+
appModel.jobs = new Map(snapshot.jobs);
|
|
68
|
+
appModel.runAggregates = snapshot.runAggregates ?? null;
|
|
69
|
+
// Fail-closed on unknown schema versions: a future incompatible ledger is
|
|
70
|
+
// never trusted as V1 data (fixes: schemaVersion must be validated at read).
|
|
71
|
+
appModel.evidenceAvailability = isSupportedEvidenceAvailability(snapshot.evidenceAvailability)
|
|
72
|
+
? snapshot.evidenceAvailability
|
|
73
|
+
: null;
|
|
74
|
+
|
|
75
|
+
taskDataCache.clear();
|
|
76
|
+
for (const [k, v] of snapshot.taskData) taskDataCache.set(k, v);
|
|
77
|
+
|
|
78
|
+
return [...snapshot.catalog];
|
|
79
|
+
}
|