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,288 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { parseArgs } from 'node:util';
|
|
3
|
+
import { writeFileSync, existsSync, realpathSync } from 'node:fs';
|
|
4
|
+
import { join, dirname } from 'node:path';
|
|
5
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
6
|
+
|
|
7
|
+
const binDir = dirname(fileURLToPath(import.meta.url));
|
|
8
|
+
const pkgDir = dirname(binDir);
|
|
9
|
+
|
|
10
|
+
// vendor-core/ exists only in a published/standalone install (populated by
|
|
11
|
+
// scripts/vendor-core.mjs at pack time); in the monorepo it falls back to
|
|
12
|
+
// the real packages/core/src/ sibling. load-vendored.js itself is the one
|
|
13
|
+
// module this bootstrap has to locate by hand (see its own header comment);
|
|
14
|
+
// every other core module then loads through its exported loadVendored().
|
|
15
|
+
const vendoredHelper = join(pkgDir, 'vendor-core', 'load-vendored.js');
|
|
16
|
+
const helperPath = existsSync(vendoredHelper) ? vendoredHelper : join(pkgDir, '..', 'core', 'src', 'load-vendored.js');
|
|
17
|
+
const { loadVendored } = await import(pathToFileURL(helperPath).href);
|
|
18
|
+
const loadCore = (moduleName) => loadVendored(pkgDir, moduleName);
|
|
19
|
+
|
|
20
|
+
const { collectRun } = await loadCore('cli/collect-run');
|
|
21
|
+
const { resolveFromShs } = await loadCore('shs-load');
|
|
22
|
+
const { analyze } = await loadCore('analyzer');
|
|
23
|
+
const { deriveEvidenceAvailability } = await loadCore('evidence-availability');
|
|
24
|
+
const { buildEvidenceReport, toFindingsFilter } = await loadCore('evidence-report');
|
|
25
|
+
const { evaluateBudgets } = await loadCore('cli/budgets');
|
|
26
|
+
const { buildComparison, renderComparisonMarkdown } = await loadCore('run-comparison');
|
|
27
|
+
const { redactComparison } = await loadCore('redact');
|
|
28
|
+
|
|
29
|
+
const USAGE = `Usage: sparkforensics-analyze <event-log-file|rolling-log-dir> [options]
|
|
30
|
+
sparkforensics-analyze --shs-base-url <url> --app-id <id> [--attempt-id <id>] [options]
|
|
31
|
+
|
|
32
|
+
Options:
|
|
33
|
+
--format md|json Output format (default: json).
|
|
34
|
+
--out <path> Write output to a file instead of stdout.
|
|
35
|
+
--max-runtime <ms> Fail if app runtime exceeds this many ms.
|
|
36
|
+
--max-spill <gb> Fail if any stage spills more than this many GB.
|
|
37
|
+
--max-skew <ratio> Fail if any stage's P95/median duration ratio exceeds this.
|
|
38
|
+
--max-failed-task-rate <pct> Fail if the task failure rate exceeds this percent.
|
|
39
|
+
--min-efficiency <pct> Fail if compute efficiency falls below this percent.
|
|
40
|
+
--shs-base-url <url> Fetch the run from a Spark History Server instead of a
|
|
41
|
+
local file (mutually exclusive with the positional argument).
|
|
42
|
+
--app-id <id> Spark application ID to fetch. Required with --shs-base-url.
|
|
43
|
+
--attempt-id <id> Optional attempt ID, used with --shs-base-url.
|
|
44
|
+
--baseline <path> Compare the run against a baseline local event-log file or
|
|
45
|
+
rolling-log directory. Baseline is local-path-only (no SHS
|
|
46
|
+
support). Adds a comparison section to the output.
|
|
47
|
+
--max-regression-pct <pct> Requires --baseline. Fail if the regression metric (see
|
|
48
|
+
--regression-metric) regressed by more than this percent.
|
|
49
|
+
--regression-metric <key> Metric key to check with --max-regression-pct (default:
|
|
50
|
+
wallClock). Requires --baseline and --max-regression-pct.
|
|
51
|
+
--fail-on-introduced <band|all> Requires --baseline. Fail if any finding was introduced by
|
|
52
|
+
the candidate matching this impact band (or any, with "all").
|
|
53
|
+
--redact Pseudonymize the app id and any host/IP tokens in the output
|
|
54
|
+
(app-1, host-1, ...), so a report can be shared outside the
|
|
55
|
+
environment that produced it.
|
|
56
|
+
--impact <band[,band]> Filter the output's findings array to these impact bands
|
|
57
|
+
(critical, warning, info). recommendations/cleanChecks and
|
|
58
|
+
the summary counts stay on the full, unfiltered set.
|
|
59
|
+
--type <type[,type]> Filter the output's findings array to these finding types.
|
|
60
|
+
--stage <id> Filter the output's findings array to this stage id.
|
|
61
|
+
|
|
62
|
+
Exit codes: 0 pass, 1 budget violated, 2 bad arguments, the local input could not be parsed, or the --shs-base-url fetch failed, 3 a budget was inconclusive.
|
|
63
|
+
`;
|
|
64
|
+
|
|
65
|
+
function parseCliArgs(argv) {
|
|
66
|
+
const { values, positionals } = parseArgs({
|
|
67
|
+
args: argv,
|
|
68
|
+
allowPositionals: true,
|
|
69
|
+
options: {
|
|
70
|
+
out: { type: 'string' },
|
|
71
|
+
format: { type: 'string' },
|
|
72
|
+
'max-runtime': { type: 'string' },
|
|
73
|
+
'max-spill': { type: 'string' },
|
|
74
|
+
'max-skew': { type: 'string' },
|
|
75
|
+
'max-failed-task-rate': { type: 'string' },
|
|
76
|
+
'min-efficiency': { type: 'string' },
|
|
77
|
+
'shs-base-url': { type: 'string' },
|
|
78
|
+
'app-id': { type: 'string' },
|
|
79
|
+
'attempt-id': { type: 'string' },
|
|
80
|
+
baseline: { type: 'string' },
|
|
81
|
+
'max-regression-pct': { type: 'string' },
|
|
82
|
+
'regression-metric': { type: 'string' },
|
|
83
|
+
'fail-on-introduced': { type: 'string' },
|
|
84
|
+
redact: { type: 'boolean' },
|
|
85
|
+
impact: { type: 'string' },
|
|
86
|
+
type: { type: 'string' },
|
|
87
|
+
stage: { type: 'string' },
|
|
88
|
+
help: { type: 'boolean' },
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
return { values, positionals };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function bail(message, code) {
|
|
95
|
+
process.stderr.write(message);
|
|
96
|
+
process.exitCode = code;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function splitCsv(value) {
|
|
100
|
+
if (value === undefined) return undefined;
|
|
101
|
+
return value.split(',').map((s) => s.trim()).filter(Boolean);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function collectWithEvidence(path) {
|
|
105
|
+
const { appModel, skippedLines } = await collectRun(path);
|
|
106
|
+
appModel.evidenceAvailability = deriveEvidenceAvailability(appModel, { skippedLines });
|
|
107
|
+
return appModel;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export async function main(argv, { fetchImpl } = {}) {
|
|
111
|
+
const { values, positionals } = parseCliArgs(argv);
|
|
112
|
+
if (values.help) {
|
|
113
|
+
process.stderr.write(USAGE);
|
|
114
|
+
process.exitCode = 0;
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const usingShs = values['shs-base-url'] !== undefined;
|
|
119
|
+
if (usingShs) {
|
|
120
|
+
if (positionals.length > 0) {
|
|
121
|
+
return bail(`Pass either <event-log-file|rolling-log-dir> or --shs-base-url, not both.\n${USAGE}`, 2);
|
|
122
|
+
}
|
|
123
|
+
if (values['app-id'] === undefined) {
|
|
124
|
+
return bail(`--shs-base-url requires --app-id.\n${USAGE}`, 2);
|
|
125
|
+
}
|
|
126
|
+
} else {
|
|
127
|
+
if (positionals.length !== 1) {
|
|
128
|
+
return bail(USAGE, 2);
|
|
129
|
+
}
|
|
130
|
+
if (values['app-id'] !== undefined || values['attempt-id'] !== undefined) {
|
|
131
|
+
return bail(`--app-id/--attempt-id require --shs-base-url.\n${USAGE}`, 2);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (values.format !== undefined && values.format !== 'json' && values.format !== 'md') {
|
|
136
|
+
return bail(`Invalid value for --format (expected "json" or "md").\n${USAGE}`, 2);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const usingBaseline = values.baseline !== undefined;
|
|
140
|
+
// Single source of truth for which flags need --baseline: a future flag
|
|
141
|
+
// just gets appended here, instead of also needing its own OR-condition
|
|
142
|
+
// above (easy to forget, and forgetting it means the flag is silently
|
|
143
|
+
// accepted without --baseline instead of erroring).
|
|
144
|
+
const BASELINE_DEPENDENT_FLAGS = ['max-regression-pct', 'regression-metric', 'fail-on-introduced'];
|
|
145
|
+
if (!usingBaseline && BASELINE_DEPENDENT_FLAGS.some((flag) => values[flag] !== undefined)) {
|
|
146
|
+
return bail(`--max-regression-pct/--regression-metric/--fail-on-introduced require --baseline.\n${USAGE}`, 2);
|
|
147
|
+
}
|
|
148
|
+
if (values['regression-metric'] !== undefined && values['max-regression-pct'] === undefined) {
|
|
149
|
+
return bail(`--regression-metric requires --max-regression-pct.\n${USAGE}`, 2);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const budgets = {
|
|
153
|
+
maxRuntimeMs: values['max-runtime'] != null ? Number(values['max-runtime']) : undefined,
|
|
154
|
+
maxSpillGb: values['max-spill'] != null ? Number(values['max-spill']) : undefined,
|
|
155
|
+
maxSkewRatio: values['max-skew'] != null ? Number(values['max-skew']) : undefined,
|
|
156
|
+
maxFailedTaskRatePct: values['max-failed-task-rate'] != null ? Number(values['max-failed-task-rate']) : undefined,
|
|
157
|
+
minEfficiencyPct: values['min-efficiency'] != null ? Number(values['min-efficiency']) : undefined,
|
|
158
|
+
maxRegressionPct: values['max-regression-pct'] != null ? Number(values['max-regression-pct']) : undefined,
|
|
159
|
+
};
|
|
160
|
+
// Derived from `budgets`, not hardcoded: every key on that object literal
|
|
161
|
+
// above is numeric (regressionMetric/failOnIntroduced are only added below,
|
|
162
|
+
// after this validation loop runs), so a future numeric budget field can't
|
|
163
|
+
// silently skip this check by only being added to one of the two places.
|
|
164
|
+
const NUMERIC_BUDGET_FLAGS = Object.keys(budgets);
|
|
165
|
+
for (const flag of NUMERIC_BUDGET_FLAGS) {
|
|
166
|
+
const value = budgets[flag];
|
|
167
|
+
if (value !== undefined && !Number.isFinite(value)) {
|
|
168
|
+
return bail(`Invalid numeric value for ${flag}.\n${USAGE}`, 2);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
// Only set when the user actually passed --regression-metric: evaluateBudgets()
|
|
172
|
+
// now treats "regressionMetric is set" as "the caller asked for a regression
|
|
173
|
+
// check" (guarding against it being set without --max-regression-pct), so
|
|
174
|
+
// defaulting it here unconditionally would trip that guard on every run that
|
|
175
|
+
// never touched either flag. checkRegression already falls back to
|
|
176
|
+
// 'wallClock' itself (`budgets.regressionMetric ?? 'wallClock'`) once
|
|
177
|
+
// maxRegressionPct is actually set, so no default is needed here.
|
|
178
|
+
if (values['regression-metric'] !== undefined) budgets.regressionMetric = values['regression-metric'];
|
|
179
|
+
if (values['fail-on-introduced'] !== undefined) budgets.failOnIntroduced = values['fail-on-introduced'];
|
|
180
|
+
|
|
181
|
+
const impactBand = splitCsv(values.impact);
|
|
182
|
+
const type = splitCsv(values.type);
|
|
183
|
+
let stageId;
|
|
184
|
+
if (values.stage !== undefined) {
|
|
185
|
+
stageId = Number(values.stage);
|
|
186
|
+
if (!Number.isInteger(stageId)) {
|
|
187
|
+
return bail(`Invalid integer value for --stage.\n${USAGE}`, 2);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
const findingsFilter = toFindingsFilter(impactBand, type, stageId);
|
|
191
|
+
|
|
192
|
+
let appModel;
|
|
193
|
+
let baselineAppModel;
|
|
194
|
+
try {
|
|
195
|
+
if (usingShs) {
|
|
196
|
+
const shsPromise = resolveFromShs(
|
|
197
|
+
values['shs-base-url'], values['app-id'], values['attempt-id'],
|
|
198
|
+
fetchImpl !== undefined ? { fetchImpl } : {},
|
|
199
|
+
);
|
|
200
|
+
// The SHS fetch (network) and a local --baseline parse (disk) are
|
|
201
|
+
// independent, so run them concurrently. Not applied to the
|
|
202
|
+
// local-candidate+local-baseline combination below: both sides there
|
|
203
|
+
// are synchronous-equivalent CPU work reading the same way, so
|
|
204
|
+
// parallelizing wouldn't help and would just add indirection.
|
|
205
|
+
if (usingBaseline) {
|
|
206
|
+
[appModel, baselineAppModel] = await Promise.all([shsPromise, collectWithEvidence(values.baseline)]);
|
|
207
|
+
} else {
|
|
208
|
+
appModel = await shsPromise;
|
|
209
|
+
}
|
|
210
|
+
} else {
|
|
211
|
+
appModel = await collectWithEvidence(positionals[0]);
|
|
212
|
+
if (usingBaseline) baselineAppModel = await collectWithEvidence(values.baseline);
|
|
213
|
+
}
|
|
214
|
+
} catch (e) {
|
|
215
|
+
process.stderr.write(`${e.message}\n`);
|
|
216
|
+
process.exitCode = 2;
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const analyzeModel = (model) => analyze(
|
|
221
|
+
model.app, model.stages, model.executors.added, model.executors.removed,
|
|
222
|
+
model.jobs, model.sql, model.runAggregates,
|
|
223
|
+
);
|
|
224
|
+
const catalog = analyzeModel(appModel);
|
|
225
|
+
|
|
226
|
+
let comparison;
|
|
227
|
+
if (usingBaseline) {
|
|
228
|
+
const baselineCatalog = analyzeModel(baselineAppModel);
|
|
229
|
+
comparison = buildComparison(
|
|
230
|
+
{ label: 'baseline', appModel: baselineAppModel, catalog: baselineCatalog },
|
|
231
|
+
{ label: 'candidate', appModel, catalog },
|
|
232
|
+
);
|
|
233
|
+
// --redact must also scrub the comparison section: stage names in
|
|
234
|
+
// comparison.findings/baseStages/candStages carry raw Spark stage text,
|
|
235
|
+
// which is exactly what --redact promises to pseudonymize.
|
|
236
|
+
if (values.redact) comparison = redactComparison(comparison);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const { markdown, json } = buildEvidenceReport(appModel, { redact: values.redact, findingsFilter, markdown: values.format === 'md' });
|
|
240
|
+
let output;
|
|
241
|
+
if (values.format === 'md') {
|
|
242
|
+
output = comparison ? `${markdown}${renderComparisonMarkdown(comparison)}\n` : `${markdown}\n`;
|
|
243
|
+
} else {
|
|
244
|
+
const payload = comparison
|
|
245
|
+
? {
|
|
246
|
+
candidate: json,
|
|
247
|
+
comparison: {
|
|
248
|
+
confidence: comparison.confidence,
|
|
249
|
+
reason: comparison.reason,
|
|
250
|
+
matchedCoverage: comparison.matchedCoverage,
|
|
251
|
+
metrics: comparison.metrics,
|
|
252
|
+
findings: comparison.findings,
|
|
253
|
+
},
|
|
254
|
+
}
|
|
255
|
+
: json;
|
|
256
|
+
output = `${JSON.stringify(payload, null, 2)}\n`;
|
|
257
|
+
}
|
|
258
|
+
if (values.out) writeFileSync(values.out, output);
|
|
259
|
+
else process.stdout.write(output);
|
|
260
|
+
|
|
261
|
+
const { results, violated, inconclusive } = evaluateBudgets({ appModel, catalog, budgets, comparison });
|
|
262
|
+
for (const r of results) {
|
|
263
|
+
if (r.status === 'inconclusive') process.stderr.write(`[inconclusive] ${r.name}: ${r.detail}\n`);
|
|
264
|
+
else if (r.status === 'violation') process.stderr.write(`[violation] ${r.name}: ${r.detail}\n`);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Always checked, unlike the opt-in budgets above: a run whose event log
|
|
268
|
+
// never recorded an ApplicationEnd is inconclusive by default, not just
|
|
269
|
+
// when the caller happens to pass --max-runtime.
|
|
270
|
+
const incompleteRunFinding = catalog.find((f) => f.type === 'incompleteRun');
|
|
271
|
+
if (incompleteRunFinding) process.stderr.write(`[inconclusive] run-complete: ${incompleteRunFinding.recommendation}\n`);
|
|
272
|
+
|
|
273
|
+
if (violated) process.exitCode = 1;
|
|
274
|
+
else if (inconclusive || incompleteRunFinding) process.exitCode = 3;
|
|
275
|
+
else process.exitCode = 0;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Guarded so tests can import `main` for in-process runs (e.g. mocking the
|
|
279
|
+
// global `fetch` that resolveFromShs falls back to) without also triggering
|
|
280
|
+
// a second, real invocation as a side effect of the import. A published
|
|
281
|
+
// install's `npm install` bin creates a node_modules/.bin/ symlink (see
|
|
282
|
+
// server/index.js's invokedDirectly for the same fix), so argv[1] must be
|
|
283
|
+
// realpath-resolved before comparing against this module's real path:
|
|
284
|
+
// otherwise the guard never matches when invoked through that symlink and
|
|
285
|
+
// the CLI silently does nothing (exit 0, no output).
|
|
286
|
+
if (process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
287
|
+
await main(process.argv.slice(2));
|
|
288
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "sparkforensics-cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"description": "CLI to analyze Apache Spark event logs for performance bottlenecks: skew, spill, GC pressure, stragglers, and more.",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/shuffle-works/sparkforensics.git"
|
|
10
|
+
},
|
|
11
|
+
"bin": {
|
|
12
|
+
"sparkforensics-analyze": "./bin/sparkforensics-analyze.mjs"
|
|
13
|
+
},
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=18"
|
|
16
|
+
},
|
|
17
|
+
"files": ["bin/", "vendor-core/"],
|
|
18
|
+
"scripts": {
|
|
19
|
+
"prepack": "node ../../scripts/vendor-core.mjs .",
|
|
20
|
+
"test": "vitest run"
|
|
21
|
+
},
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"zod": "^4.4.3"
|
|
24
|
+
},
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"@sparkforensics/core": "*",
|
|
27
|
+
"vitest": "^4.1.10"
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { DETECTORS, } from './detectors.js';
|
|
2
|
+
import { computePeakConcurrentCores } from './core-count.js';
|
|
3
|
+
import { assertNever } from './assert-never.js';
|
|
4
|
+
import { estimateImpact } from './impact-estimator.js';
|
|
5
|
+
import { computeOccupancy, } from './occupancy.js';
|
|
6
|
+
import { deriveImpactBand } from './impact-band.js';
|
|
7
|
+
import { IMPACT_BAND_ORDER } from './format-utils.js';
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
// FNV-1a 32-bit: a small, dependency-free stable string hash. Used to derive
|
|
13
|
+
// a deterministic finding `id` at the single choke point below so the same
|
|
14
|
+
// logical finding keeps the same id across runs (no timestamps, no randomness).
|
|
15
|
+
function fnv1a(str ) {
|
|
16
|
+
let h = 0x811c9dc5;
|
|
17
|
+
for (let i = 0; i < str.length; i++) {
|
|
18
|
+
h ^= str.charCodeAt(i);
|
|
19
|
+
h = Math.imul(h, 0x01000193);
|
|
20
|
+
}
|
|
21
|
+
return (h >>> 0).toString(16).padStart(8, '0');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function findingId(f ) {
|
|
25
|
+
// Location key mirrors the report's stable identity: stage, else SQL
|
|
26
|
+
// execution, else the audited config property.
|
|
27
|
+
const locKey = f.stageId ?? f.executionId ?? f.property ?? '';
|
|
28
|
+
// Discriminators for detectors that intentionally emit multiple findings on
|
|
29
|
+
// the same location+metric; without them these siblings hash to one id:
|
|
30
|
+
// stage-scope: slowHost (host), memoryUtilization (executorId + dimension),
|
|
31
|
+
// partitionSizing (rule);
|
|
32
|
+
// app-scope: cacheUtilization (rddId + variant, stageId is always null),
|
|
33
|
+
// cachingOpportunity (stageId/executionId both null too: `relation`/
|
|
34
|
+
// `format` distinguish leaf findings, `operator`+`relation` distinguish
|
|
35
|
+
// composite findings, `executionIds` as a last resort when everything
|
|
36
|
+
// else matches);
|
|
37
|
+
// SQL plan-advisor: smallFiles (direction/nodeName),
|
|
38
|
+
// duplicatePlanSubtree (rootName/subtreeSize/groupIndex: two unrelated
|
|
39
|
+
// duplicate-subtree groups can share rootName+subtreeSize, e.g. the same
|
|
40
|
+
// "BroadcastExchange over a Project/Filter/Scan" shape repeated once per
|
|
41
|
+
// dimension table; groupIndex (the group's deterministic position within
|
|
42
|
+
// one execution's plan) is the actual uniqueness guarantee, since the
|
|
43
|
+
// informational `sampleRelation` field can be null or coincide for two
|
|
44
|
+
// groups),
|
|
45
|
+
// broadcastSizing (per node/side, distinguished by value + largerSideBytes).
|
|
46
|
+
// The metric `value` folds in the per-node magnitude that broadcastSizing
|
|
47
|
+
// siblings carry no other field for; it is deterministic for a given log, so
|
|
48
|
+
// the id stays stable across re-parses (no timestamp/randomness).
|
|
49
|
+
// memoryUtilization's `rule` (heapNearCapacity/heapOverProvisioned) is excluded here: an
|
|
50
|
+
// executor falls in exactly one heap band, so `executorId` alone already guarantees
|
|
51
|
+
// uniqueness and folding `rule` in too would just add gratuitous id-rotation risk if the
|
|
52
|
+
// band logic ever changes. partitionSizing's `rule` (maxPartitionTooBig/
|
|
53
|
+
// shufflePartitionSkew/lowShuffleParallelism) IS load-bearing here: a stage can emit more
|
|
54
|
+
// than one of those rules at once, sharing the same stageId+metric.
|
|
55
|
+
const rule = f.type === 'memoryUtilization' ? undefined : f.rule;
|
|
56
|
+
const disc = [
|
|
57
|
+
f.host, f.executorId, rule, f.variant, f.dimension,
|
|
58
|
+
f.direction, f.nodeName, f.rootName, f.subtreeSize, f.groupIndex, f.largerSideBytes,
|
|
59
|
+
f.rddId, f.relation, f.format, f.operator,
|
|
60
|
+
f.executionIds ? f.executionIds.join(',') : '',
|
|
61
|
+
].map((v) => v ?? '').join('|');
|
|
62
|
+
return fnv1a(`${f.type}|${locKey}|${f.metric ?? ''}|${f.value ?? ''}|${disc}`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function push(out , entry , result ) {
|
|
66
|
+
if (!result) return;
|
|
67
|
+
const detectorVersion = entry.version ?? 1;
|
|
68
|
+
for (const f of (Array.isArray(result) ? result : [result])) {
|
|
69
|
+
if (!f) continue;
|
|
70
|
+
if (entry.suppressWhen && entry.suppressWhen(f, out)) continue;
|
|
71
|
+
const stamped = { ...f, docAnchor: f.docAnchor ?? entry.docAnchor, detectorVersion };
|
|
72
|
+
const id = findingId(stamped);
|
|
73
|
+
// Guardrail against any detector (this one or a future one) emitting two
|
|
74
|
+
// structurally-identical findings for what should be one logical
|
|
75
|
+
// occurrence: same id => same finding, keep only the first. Added after a
|
|
76
|
+
// real-log bug where duplicatePlanSubtree emitted two distinct findings
|
|
77
|
+
// sharing one id (see findingId's groupIndex discriminator above and
|
|
78
|
+
// tests/detectors-plan.test.js's duplicatePlanSubtree regression tests);
|
|
79
|
+
// this guard's correctness depends on findingId's discriminator list
|
|
80
|
+
// actually being unique per distinct finding, not on this line itself.
|
|
81
|
+
if (out.some((existing) => existing.id === id)) continue;
|
|
82
|
+
out.push({ ...stamped, id });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// `app` is widened to `SparkAppInfo | null` rather than the plan's literal
|
|
87
|
+
// non-nullable `SparkAppInfo`: real callers (evidence-report.ts's
|
|
88
|
+
// `buildJson`, useIngest.ts's `runDone`/`snapshotParsedRun`) pass
|
|
89
|
+
// `AppModel.app`, which is genuinely `SparkAppInfo | null` at the type
|
|
90
|
+
// level even though by the time `analyze()` runs in practice a full parse
|
|
91
|
+
// has completed and `app` is always populated (the same reasoning
|
|
92
|
+
// detectors.ts's `DetectorCtx.app` comment gives for treating its own,
|
|
93
|
+
// separate `app` field as non-nullable). Widened to match the real caller
|
|
94
|
+
// type rather than forcing a cast at every call site.
|
|
95
|
+
export function analyze(
|
|
96
|
+
app ,
|
|
97
|
+
stages ,
|
|
98
|
+
executorsAdded ,
|
|
99
|
+
executorsRemoved ,
|
|
100
|
+
jobs ,
|
|
101
|
+
sql = new Map(),
|
|
102
|
+
runAggregates = null,
|
|
103
|
+
) {
|
|
104
|
+
// `app ?? {}`: every detector below tolerates a null `app` (malformed logs
|
|
105
|
+
// with no ApplicationStart), so this call must too; computePeakConcurrentCores
|
|
106
|
+
// just falls back to the executor-derived core sum when `resources` is absent.
|
|
107
|
+
// computePeakConcurrentCores (not computeTotalCores) here specifically: the
|
|
108
|
+
// ceiling this feeds (src/occupancy.ts's computeCeiling) needs a genuine
|
|
109
|
+
// concurrent-capacity bound, and computeTotalCores's cumulative sum over
|
|
110
|
+
// every addition overstates that under dynamic allocation/executor
|
|
111
|
+
// replacement (a churned-through executor's cores were never actually
|
|
112
|
+
// concurrent with its replacement's).
|
|
113
|
+
// `executorsAdded`/`executorsRemoved` casts: computePeakConcurrentCores only
|
|
114
|
+
// reads `executorId`/`timestamp`/`totalCores`, all present on
|
|
115
|
+
// ExecutorAddedEvent/ExecutorRemovedEvent, but `totalCores` isn't on
|
|
116
|
+
// ExecutorRemovedEvent, so the `ExecutorEvent` union as a whole is a
|
|
117
|
+
// structural mismatch against its parameter shapes.
|
|
118
|
+
const totalCores = computePeakConcurrentCores(
|
|
119
|
+
app ?? {},
|
|
120
|
+
executorsAdded ,
|
|
121
|
+
executorsRemoved ,
|
|
122
|
+
);
|
|
123
|
+
// Computed once, up front, so detectors can gate impact band on the same
|
|
124
|
+
// occupancy-clipped waste figure estimateImpact() below displays as that
|
|
125
|
+
// finding's savings (src/detectors.ts's `clippedWasteMs`), not a raw
|
|
126
|
+
// pre-clip delta the two passes would otherwise disagree on.
|
|
127
|
+
const occupancy = computeOccupancy(stages , totalCores);
|
|
128
|
+
const ctx = {
|
|
129
|
+
app, stages, executorsAdded, executorsRemoved, jobs, sql, runAggregates, occupancy,
|
|
130
|
+
};
|
|
131
|
+
const out = [];
|
|
132
|
+
for (const d of DETECTORS) {
|
|
133
|
+
if (d.inScorecard === false) continue;
|
|
134
|
+
switch (d.scope) {
|
|
135
|
+
case 'stage':
|
|
136
|
+
for (const s of stages.values()) push(out, d, d.detect(s, ctx));
|
|
137
|
+
break;
|
|
138
|
+
case 'sql':
|
|
139
|
+
for (const e of sql.values()) push(out, d, d.detect(e, ctx));
|
|
140
|
+
break;
|
|
141
|
+
case 'app':
|
|
142
|
+
case 'config':
|
|
143
|
+
push(out, d, d.detect(ctx));
|
|
144
|
+
break;
|
|
145
|
+
default:
|
|
146
|
+
assertNever(d.scope);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
estimateImpact(out, stages, totalCores);
|
|
150
|
+
deriveImpactBand(out, app);
|
|
151
|
+
// Ascending IMPACT_BAND_ORDER (critical 0 → info 2) puts the worst band
|
|
152
|
+
// first, matching the old descending 3/2/1 rank this replaced; `sort` is
|
|
153
|
+
// stable, so findings sharing a band keep their DETECTORS declaration order.
|
|
154
|
+
out.sort((a, b) => IMPACT_BAND_ORDER[a.impactBand] - IMPACT_BAND_ORDER[b.impactBand]);
|
|
155
|
+
return out;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function auditConfig(app ) {
|
|
159
|
+
const out = [];
|
|
160
|
+
for (const d of DETECTORS) if (d.scope === 'config') push(out, d, d.detect({ app }));
|
|
161
|
+
// configAudit's own impact-estimator case (src/impact-estimator.ts) needs neither
|
|
162
|
+
// `stages` nor `totalCores`: it's unconditionally `costOnly('none')`. An empty stages
|
|
163
|
+
// map is enough for parity with the same finding type produced via analyze().
|
|
164
|
+
estimateImpact(out, new Map());
|
|
165
|
+
deriveImpactBand(out, app);
|
|
166
|
+
return out.map((f) => ({ ...f, stageId: f.stageId ?? null }));
|
|
167
|
+
}
|