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,197 @@
|
|
|
1
|
+
// Explicit .ts extensions (unlike a bundler, plain Node's ESM resolver -
|
|
2
|
+
// the runtime bin/sparkforensics-analyze.mjs and src/mcp-tools.ts both run
|
|
3
|
+
// under, now that src/evidence-report.ts imports this module too - requires
|
|
4
|
+
// the exact specifier, not an extensionless one a bundler would resolve for
|
|
5
|
+
// you): this file's prior only consumer, src/view/widgets/FixTheseFirst.tsx,
|
|
6
|
+
// went through Vite, which tolerates the extensionless form, so this never
|
|
7
|
+
// surfaced until the CLI/MCP path started importing it.
|
|
8
|
+
import { mergeIntervals } from './wall-clock.js';
|
|
9
|
+
import { worstImpactBand, IMPACT_BAND_ORDER } from './format-utils.js';
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
/** The cross-finding grouping layer the spec calls for: given every stage
|
|
18
|
+
* touched by a group of findings, collect their intervals and merge once
|
|
19
|
+
* across the combined set, instead of summing each finding's own
|
|
20
|
+
* already-clipped wallClock figure (which double-counts concurrent stages
|
|
21
|
+
* across different findings of the same detector type). Reuses the generic
|
|
22
|
+
* mergeIntervals primitive (src/wall-clock.ts) rather than widening the
|
|
23
|
+
* single-finding-scoped estimateMultiStage/unionMs in src/occupancy.ts. */
|
|
24
|
+
export function computeStageUnionMs(
|
|
25
|
+
stageIds ,
|
|
26
|
+
stages ,
|
|
27
|
+
) {
|
|
28
|
+
const intervals = [];
|
|
29
|
+
for (const id of stageIds) {
|
|
30
|
+
const stage = stages.get(id);
|
|
31
|
+
if (!stage) continue;
|
|
32
|
+
// Skip half-open stages rather than defaulting a missing bound to 0: an
|
|
33
|
+
// incomplete/truncated log (the case `incompleteRun` flags) leaves
|
|
34
|
+
// `completedAt` unset, and coercing it to 0 would contribute a hugely
|
|
35
|
+
// negative interval and a nonsense negative recoverable-time figure.
|
|
36
|
+
// Same filter `computeWallClock` (src/wall-clock.ts) already applies.
|
|
37
|
+
if (stage.submittedAt == null || stage.completedAt == null) continue;
|
|
38
|
+
intervals.push([stage.submittedAt, stage.completedAt]);
|
|
39
|
+
}
|
|
40
|
+
return mergeIntervals(intervals).reduce((sum, [a, b]) => sum + (b - a), 0);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
function groupByType(findings ) {
|
|
49
|
+
const groups = new Map ();
|
|
50
|
+
for (const finding of findings) {
|
|
51
|
+
const group = groups.get(finding.type) ?? [];
|
|
52
|
+
group.push(finding);
|
|
53
|
+
groups.set(finding.type, group);
|
|
54
|
+
}
|
|
55
|
+
return groups;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function stageIdsOf(finding ) {
|
|
59
|
+
if (finding.stageIds) return finding.stageIds;
|
|
60
|
+
if (finding.stageId != null) return [finding.stageId];
|
|
61
|
+
return [];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function buildTimeGroup(
|
|
65
|
+
type ,
|
|
66
|
+
findings ,
|
|
67
|
+
stages ,
|
|
68
|
+
) {
|
|
69
|
+
const stageIds = [...new Set(findings.flatMap(stageIdsOf))];
|
|
70
|
+
const naiveSum = findings.reduce((sum, f) => sum + (f.impactEstimate?.wallClock?.high ?? 0), 0);
|
|
71
|
+
// Only cap to stage union if there are stages to compare; stageless findings use naive sum.
|
|
72
|
+
const recoverableMsHigh = stageIds.length > 0
|
|
73
|
+
? Math.min(naiveSum, computeStageUnionMs(stageIds, stages))
|
|
74
|
+
: naiveSum;
|
|
75
|
+
return { kind: 'time', type, findingCount: findings.length, stageCount: stageIds.length, recoverableMsHigh, findings };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function buildResourceGroup(type , findings ) {
|
|
79
|
+
const unit = findings[0].impactEstimate .rawWaste .unit;
|
|
80
|
+
const total = findings.reduce((sum, f) => sum + (f.impactEstimate?.rawWaste?.value ?? 0), 0);
|
|
81
|
+
return { kind: 'resource', type, findingCount: findings.length, unit, total, findings };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function buildCountGroup(type , findings ) {
|
|
85
|
+
const byImpactBand = {};
|
|
86
|
+
for (const finding of findings) {
|
|
87
|
+
byImpactBand[finding.impactBand] = (byImpactBand[finding.impactBand] ?? 0) + 1;
|
|
88
|
+
}
|
|
89
|
+
return { kind: 'count', type, findingCount: findings.length, byImpactBand, findings };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const KIND_ORDER = { time: 0, resource: 1, count: 2 };
|
|
93
|
+
|
|
94
|
+
export function buildRecommendationRollup(
|
|
95
|
+
findings ,
|
|
96
|
+
stages ,
|
|
97
|
+
) {
|
|
98
|
+
const groups = [];
|
|
99
|
+
for (const [type, typeFindings] of groupByType(findings)) {
|
|
100
|
+
const timeFindings = typeFindings.filter((f) => f.impactEstimate?.wallClock != null);
|
|
101
|
+
const resourceFindings = typeFindings.filter(
|
|
102
|
+
(f) => f.impactEstimate?.wallClock == null && f.impactEstimate?.rawWaste != null,
|
|
103
|
+
);
|
|
104
|
+
const countFindings = typeFindings.filter(
|
|
105
|
+
(f) => f.impactEstimate?.wallClock == null && f.impactEstimate?.rawWaste == null,
|
|
106
|
+
);
|
|
107
|
+
if (timeFindings.length > 0) groups.push(buildTimeGroup(type, timeFindings, stages));
|
|
108
|
+
// Group resource findings by (type, unit) to prevent mixing incompatible units.
|
|
109
|
+
if (resourceFindings.length > 0) {
|
|
110
|
+
const byUnit = new Map ();
|
|
111
|
+
for (const finding of resourceFindings) {
|
|
112
|
+
const unit = finding.impactEstimate .rawWaste .unit;
|
|
113
|
+
const group = byUnit.get(unit) ?? [];
|
|
114
|
+
group.push(finding);
|
|
115
|
+
byUnit.set(unit, group);
|
|
116
|
+
}
|
|
117
|
+
for (const resourceGroup of byUnit.values()) {
|
|
118
|
+
groups.push(buildResourceGroup(type, resourceGroup));
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
if (countFindings.length > 0) groups.push(buildCountGroup(type, countFindings));
|
|
122
|
+
}
|
|
123
|
+
return groups.sort((a, b) => {
|
|
124
|
+
const kindDelta = KIND_ORDER[a.kind] - KIND_ORDER[b.kind];
|
|
125
|
+
if (kindDelta !== 0) return kindDelta;
|
|
126
|
+
if (a.kind === 'time' && b.kind === 'time') return b.recoverableMsHigh - a.recoverableMsHigh;
|
|
127
|
+
// resource/count groups carry no shared magnitude to rank by (spec section
|
|
128
|
+
// 5 forbids comparing incompatible units); break ties by worst impact band
|
|
129
|
+
// instead, the same tie-break FixTheseFirst used pre-consolidation.
|
|
130
|
+
const aImpactBand = IMPACT_BAND_ORDER[worstImpactBand(a.findings) ?? 'info'] ?? 9;
|
|
131
|
+
const bImpactBand = IMPACT_BAND_ORDER[worstImpactBand(b.findings) ?? 'info'] ?? 9;
|
|
132
|
+
return aImpactBand - bImpactBand;
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** True when a finding is a candidate for the "fix these first" ranking at
|
|
137
|
+
* all. Shared by `src/evidence-report.ts` (CLI/MCP path) and
|
|
138
|
+
* `src/view/widgets/FixTheseFirst.tsx` (the dashboard widget) so these two
|
|
139
|
+
* hardcoded exclusions can't drift between the two surfaces (they used to be
|
|
140
|
+
* duplicated, near-verbatim, in both files). Doesn't check
|
|
141
|
+
* `REGISTRY[finding.type] != null` (FixTheseFirst.tsx's own extra check):
|
|
142
|
+
* `REGISTRY` lives in a `.tsx` file, not importable from this core module;
|
|
143
|
+
* FixTheseFirst.tsx layers that extra check on top of this one instead. */
|
|
144
|
+
export function isEligible(finding ) {
|
|
145
|
+
// incompleteRun is a pipeline-completeness signal, not an addressable fix:
|
|
146
|
+
// hardcoded here by type, not read from Detector.fixEffort. That field
|
|
147
|
+
// exists only to back its own contract test today (tests/fix-effort.test.js);
|
|
148
|
+
// nothing in ranking/exclusion logic reads it.
|
|
149
|
+
if (finding.type === 'incompleteRun') return false;
|
|
150
|
+
// memoryUtilization's memoryBand/dataUnavailable variant reports a missing
|
|
151
|
+
// -evidence caveat (spark.eventLog.logStageExecutorMetrics=true not on for
|
|
152
|
+
// this run), not an optimization: the exact same fact already lives in the
|
|
153
|
+
// Evidence availability ledger's own `executorMetrics` entry
|
|
154
|
+
// (src/evidence-availability.ts), so it belongs there, not here.
|
|
155
|
+
if (finding.type === 'memoryUtilization' && finding.variant === 'memoryBand' && finding.dataUnavailable) return false;
|
|
156
|
+
return true;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Ranking tier for a single finding: time-based findings (a real `wallClock`
|
|
160
|
+
* claim) are the only ones whose magnitudes share a unit, so they are the
|
|
161
|
+
* only ones ranked numerically. `resourceOnly` findings carry `rawWaste` in
|
|
162
|
+
* whatever unit their detector emitted, which the spec (section 5) forbids
|
|
163
|
+
* comparing against a time figure or against another resourceOnly finding's
|
|
164
|
+
* own raw magnitude; they rank by impact band instead, below every time-based
|
|
165
|
+
* finding. Informational findings (no magnitude at all) rank last, also by
|
|
166
|
+
* impact band. */
|
|
167
|
+
const TIER_TIME = 0;
|
|
168
|
+
const TIER_RESOURCE = 1;
|
|
169
|
+
const TIER_INFORMATIONAL = 2;
|
|
170
|
+
|
|
171
|
+
function tierOf(finding ) {
|
|
172
|
+
const estimate = finding.impactEstimate;
|
|
173
|
+
if (estimate?.wallClock) return TIER_TIME;
|
|
174
|
+
if (estimate?.rawWaste) return TIER_RESOURCE;
|
|
175
|
+
return TIER_INFORMATIONAL;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Ranks a group's members so the "representative" finding (the one whose
|
|
179
|
+
* action label/tag donate to the group's row) is always the highest-impact
|
|
180
|
+
* one, not just the first one `buildRecommendationRollup` happened to
|
|
181
|
+
* collect. Used to pick each type-group's own highest-impact member and to
|
|
182
|
+
* order that group's expanded list; cross-group ordering is
|
|
183
|
+
* `buildRecommendationRollup`'s own job, not this function's. */
|
|
184
|
+
export function rankFindings(findings ) {
|
|
185
|
+
return [...findings].sort((a, b) => {
|
|
186
|
+
const tierDelta = tierOf(a) - tierOf(b);
|
|
187
|
+
if (tierDelta !== 0) return tierDelta;
|
|
188
|
+
if (tierOf(a) === TIER_TIME) {
|
|
189
|
+
// Same unit (ms) on both sides: the one comparison that is meaningful.
|
|
190
|
+
// .high, matching deriveImpactBand (src/impact-band.ts) and
|
|
191
|
+
// triage-target.ts, so this ordering, the badge color, and the "click
|
|
192
|
+
// to investigate" target all agree on the same figure.
|
|
193
|
+
return b.impactEstimate .wallClock .high - a.impactEstimate .wallClock .high;
|
|
194
|
+
}
|
|
195
|
+
return (IMPACT_BAND_ORDER[a.impactBand] ?? 9) - (IMPACT_BAND_ORDER[b.impactBand] ?? 9);
|
|
196
|
+
});
|
|
197
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// Identifier redaction for the portable evidence report. Replaces the
|
|
2
|
+
// application id and every host name with stable pseudonyms (`app-1`,
|
|
3
|
+
// `host-1`, ...) so a report can be shared outside the environment that
|
|
4
|
+
// produced it without leaking infrastructure identity. Opt-in (default OFF)
|
|
5
|
+
// at the call site; raw task records are excluded from the report regardless.
|
|
6
|
+
//
|
|
7
|
+
// Deterministic (sorted assignment), idempotent (pseudonyms map to themselves),
|
|
8
|
+
// and non-mutating (returns a fresh, deep-copied tree).
|
|
9
|
+
|
|
10
|
+
// Host / IP identifier patterns. Used to enumerate host names that surface only
|
|
11
|
+
// inside free text: recommendation strings, `stageFailed`'s failure-reason
|
|
12
|
+
// value, SQL relation/node names, never as a structured `host` field, so
|
|
13
|
+
// redaction reaches those residuals too. Pseudonyms (`host-1`) match neither
|
|
14
|
+
// pattern, keeping the scan idempotent.
|
|
15
|
+
const HOST_PATTERNS = [
|
|
16
|
+
// EC2-style ip-10-1-2-3 with an optional dotted domain (ip-10-1-2-3.ec2.internal).
|
|
17
|
+
// Each domain label must start with an alphanumeric, so a trailing sentence
|
|
18
|
+
// period ("… bad node ip-10-1-2-3.") is left out of the match.
|
|
19
|
+
/\bip(?:-\d{1,3}){4}(?:\.[a-z0-9][a-z0-9-]*)*/gi,
|
|
20
|
+
/\b(?:\d{1,3}\.){3}\d{1,3}\b/g, // bare IPv4: 10.1.2.3
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
// Spark application id (e.g. application_1690000000000_0001). Besides
|
|
24
|
+
// redactReport's structured summary.app.id field, an app id can also surface
|
|
25
|
+
// as a residual free-text token (e.g. embedded in a finding's evidence or
|
|
26
|
+
// recommendation string) — same as a host/IP token embedded in a stage name
|
|
27
|
+
// or plan detail. redactComparison has no structured app-id field at all
|
|
28
|
+
// (baselineLabel/candidateLabel are caller-supplied labels, not Spark app
|
|
29
|
+
// ids), so free text is its *only* source of app ids.
|
|
30
|
+
const APP_ID_PATTERNS = [/\bapplication_\d{10,}_\d+\b/g];
|
|
31
|
+
|
|
32
|
+
// Walk every string in the tree once, collecting matches for each `{ patterns,
|
|
33
|
+
// out }` sink. One shared traversal for every token kind (instead of one
|
|
34
|
+
// traversal per kind) keeps redactComparison's dual host+app-id scan the same
|
|
35
|
+
// cost as the single-kind scan redactReport/redactAppIdentity already do.
|
|
36
|
+
function scanTokens(node , sinks ) {
|
|
37
|
+
if (typeof node === 'string') {
|
|
38
|
+
for (const { patterns, out } of sinks) {
|
|
39
|
+
for (const re of patterns) {
|
|
40
|
+
const found = node.match(re);
|
|
41
|
+
if (found) for (const m of found) out.add(m);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
if (Array.isArray(node)) {
|
|
47
|
+
for (const n of node) scanTokens(n, sinks);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
if (node && typeof node === 'object') {
|
|
51
|
+
for (const v of Object.values(node)) scanTokens(v, sinks);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Walk every string in the tree, collecting host/IP tokens into `hosts`.
|
|
56
|
+
function scanHostTokens(node , hosts ) {
|
|
57
|
+
scanTokens(node, [{ patterns: HOST_PATTERNS, out: hosts }]);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Known locations of the identifiers, so we don't have to guess which strings
|
|
61
|
+
// are sensitive: the app id lives at summary.app.id; host names live on each
|
|
62
|
+
// finding's `host` field. The real report nests that host under `evidence.host`
|
|
63
|
+
// (host is not a first-class report column), so read both shapes, plus a
|
|
64
|
+
// free-text scan for hosts/IPs and app ids that appear only inside string
|
|
65
|
+
// values (e.g. a finding's evidence/recommendation text).
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
function collectIds(report ) {
|
|
76
|
+
const appIds = new Set ();
|
|
77
|
+
const hosts = new Set ();
|
|
78
|
+
const appId = report?.summary?.app?.id;
|
|
79
|
+
if (typeof appId === 'string' && appId.length > 0) appIds.add(appId);
|
|
80
|
+
for (const f of report?.findings ?? []) {
|
|
81
|
+
for (const h of [f?.host, f?.evidence?.host]) {
|
|
82
|
+
if (typeof h === 'string' && h.length > 0) hosts.add(h);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
// Free-text scan for both host/IP tokens and app-id tokens: an app id can
|
|
86
|
+
// surface in a finding's evidence/recommendation text (e.g. "retry app
|
|
87
|
+
// application_1690000000000_0001 failed") same as redactComparison's scan.
|
|
88
|
+
scanTokens(report, [{ patterns: HOST_PATTERNS, out: hosts }, { patterns: APP_ID_PATTERNS, out: appIds }]);
|
|
89
|
+
return { appIds, hosts };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Numeric-aware sorted assignment => deterministic numbering that is stable
|
|
93
|
+
// across passes. A lexicographic sort orders `host-1, host-10, host-11, host-2`
|
|
94
|
+
// so a second pass over already-pseudonymized ids would re-slot `host-10`→`2`
|
|
95
|
+
// at >=10 items and break idempotency; a numeric-aware sort keeps `host-2`
|
|
96
|
+
// before `host-10`, so each pseudonym maps back to itself.
|
|
97
|
+
function buildMap(ids , prefix ) {
|
|
98
|
+
const map = new Map ();
|
|
99
|
+
[...ids]
|
|
100
|
+
.sort((a, b) => a.localeCompare(b, 'en', { numeric: true }))
|
|
101
|
+
.forEach((id, i) => map.set(id, `${prefix}-${i + 1}`));
|
|
102
|
+
return map;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Deep-replace every string occurrence of each identifier across the whole
|
|
106
|
+
// tree (covers ids embedded in free-text recommendations, not just the
|
|
107
|
+
// canonical fields). Longest-first so no identifier is a prefix-shadow of
|
|
108
|
+
// another. Returns a fresh tree, never mutates the input.
|
|
109
|
+
function deepReplace(node , replacements ) {
|
|
110
|
+
if (typeof node === 'string') {
|
|
111
|
+
let s = node;
|
|
112
|
+
for (const [from, to] of replacements) s = s.split(from).join(to);
|
|
113
|
+
return s;
|
|
114
|
+
}
|
|
115
|
+
if (Array.isArray(node)) return node.map((n) => deepReplace(n, replacements));
|
|
116
|
+
if (node && typeof node === 'object') {
|
|
117
|
+
const out = {};
|
|
118
|
+
for (const [k, v] of Object.entries(node)) out[k] = deepReplace(v, replacements);
|
|
119
|
+
return out;
|
|
120
|
+
}
|
|
121
|
+
return node;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Shared by every redact* export below: turns collected app-id/host sets into
|
|
125
|
+
// the longest-first replacement list and applies it. Centralizes the
|
|
126
|
+
// buildMap+sort+deepReplace sequence so each narrower redact* function only
|
|
127
|
+
// has to say what it collects, not how replacement is carried out.
|
|
128
|
+
function applyReplacements (node , ids ) {
|
|
129
|
+
const merged = [
|
|
130
|
+
...(ids.appIds ? buildMap(ids.appIds, 'app') : []),
|
|
131
|
+
...buildMap(ids.hosts, 'host'),
|
|
132
|
+
];
|
|
133
|
+
// Replace longer identifiers first to avoid partial-substring clobbering.
|
|
134
|
+
merged.sort((a, b) => b[0].length - a[0].length);
|
|
135
|
+
return deepReplace(node, merged) ;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function redactReport (report ) {
|
|
139
|
+
const { appIds, hosts } = collectIds(report);
|
|
140
|
+
return applyReplacements(report, { appIds, hosts });
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Narrow counterpart to redactReport(), for getRunSummary()'s standalone app
|
|
144
|
+
// object (no findings tree to walk). There's exactly one app id here, so no
|
|
145
|
+
// Set/Map/sort is needed for it; name/sparkVersion still go through the
|
|
146
|
+
// shared host/IP scan-and-replace since either can carry a host token as
|
|
147
|
+
// free text.
|
|
148
|
+
export function redactAppIdentity(
|
|
149
|
+
app ,
|
|
150
|
+
) {
|
|
151
|
+
const hosts = new Set ();
|
|
152
|
+
scanHostTokens(app.name, hosts);
|
|
153
|
+
scanHostTokens(app.sparkVersion, hosts);
|
|
154
|
+
return {
|
|
155
|
+
id: typeof app.id === 'string' && app.id.length > 0 ? 'app-1' : app.id,
|
|
156
|
+
name: applyReplacements(app.name, { hosts }),
|
|
157
|
+
sparkVersion: applyReplacements(app.sparkVersion, { hosts }),
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Run-comparison counterpart: no single app-id *field* to pseudonymize
|
|
162
|
+
// (baselineLabel/candidateLabel are caller-supplied labels, not Spark app
|
|
163
|
+
// ids), but stage names surface throughout the tree (FindingsDeltaRow.stages,
|
|
164
|
+
// baseStages/candStages[].name) and, like the evidence report's findings, can
|
|
165
|
+
// carry a host/IP token or an app id as free text (e.g. `collect at
|
|
166
|
+
// application_1690000000000_0001 worker-10-1-2-3.scala:45`). Scans the whole
|
|
167
|
+
// comparison tree rather than enumerating those fields individually, so a
|
|
168
|
+
// future CompareRunsResult field carrying free text is covered without this
|
|
169
|
+
// function needing to change.
|
|
170
|
+
export function redactComparison (comparison ) {
|
|
171
|
+
const hosts = new Set ();
|
|
172
|
+
const appIds = new Set ();
|
|
173
|
+
scanTokens(comparison, [{ patterns: HOST_PATTERNS, out: hosts }, { patterns: APP_ID_PATTERNS, out: appIds }]);
|
|
174
|
+
return applyReplacements(comparison, { appIds, hosts });
|
|
175
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// Rolling event-log directory/zip member-name reassembly. Pulled out of
|
|
2
|
+
// shs-fetch.ts (Task: bundle-size fix) so DropZone.tsx's drag-drop path can
|
|
3
|
+
// import just this reassembly logic without pulling in the Zod schemas and
|
|
4
|
+
// vendored zstd/gzip decompressors that the rest of shs-fetch.ts (and the
|
|
5
|
+
// wider parser-worker.ts module graph) depend on. Keep this file free of any
|
|
6
|
+
// import beyond plain JS/TS built-ins.
|
|
7
|
+
|
|
8
|
+
export function naturalCompare(a , b ) {
|
|
9
|
+
const tokenize = (s ) => s.match(/(\d+)|(\D+)/g) ?? [s];
|
|
10
|
+
const ax = tokenize(a), bx = tokenize(b);
|
|
11
|
+
const len = Math.max(ax.length, bx.length);
|
|
12
|
+
for (let i = 0; i < len; i++) {
|
|
13
|
+
const av = ax[i] ?? '', bv = bx[i] ?? '';
|
|
14
|
+
if (av === bv) continue;
|
|
15
|
+
const an = Number(av), bn = Number(bv);
|
|
16
|
+
if (!Number.isNaN(an) && !Number.isNaN(bn)) return an - bn;
|
|
17
|
+
return av < bv ? -1 : 1;
|
|
18
|
+
}
|
|
19
|
+
return 0;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Reassemble a rolling `eventlog_v2_*` directory's (or SHS zip's) member
|
|
23
|
+
// names into the ordered list that should actually be parsed: drop the
|
|
24
|
+
// zero-byte `appstatus_*` completion marker, drop every non-compact
|
|
25
|
+
// `events_*` file at or below the most recent `*.compact` file's index
|
|
26
|
+
// (already merged into it, so reading them again double-counts events), and
|
|
27
|
+
// sort the remainder in ascending numeric-index order. Throws if the
|
|
28
|
+
// resulting index sequence has a gap (a missing roll file).
|
|
29
|
+
export function reassembleRollingEntries(names ) {
|
|
30
|
+
const withoutMarker = names.filter(n => !n.toLowerCase().startsWith('appstatus'));
|
|
31
|
+
const eventNames = withoutMarker.filter(n => /^events_\d+_/.test(n));
|
|
32
|
+
|
|
33
|
+
const indexOf = (n ) => parseInt(n.match(/^events_(\d+)_/) [1], 10);
|
|
34
|
+
|
|
35
|
+
const compactIndices = eventNames.filter(n => n.endsWith('.compact')).map(indexOf);
|
|
36
|
+
const highestCompactIndex = compactIndices.length > 0 ? Math.max(...compactIndices) : -1;
|
|
37
|
+
|
|
38
|
+
const kept = eventNames.filter(n => {
|
|
39
|
+
const index = indexOf(n);
|
|
40
|
+
return n.endsWith('.compact') ? index === highestCompactIndex : index > highestCompactIndex;
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const sorted = kept.sort(naturalCompare);
|
|
44
|
+
const indices = sorted.map(indexOf);
|
|
45
|
+
for (let i = 1; i < indices.length; i++) {
|
|
46
|
+
if (indices[i] !== indices[i - 1] + 1) {
|
|
47
|
+
throw new Error(`Rolling event-log directory is missing file(s) between index ${indices[i - 1]} and ${indices[i]}.`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return sorted;
|
|
52
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Whole-run aggregation pass. Runs INSIDE parser-worker.js over the retained
|
|
2
|
+
// per-task launch/finish timestamps in taskStore, so raw task data never
|
|
3
|
+
// reaches the main thread: only the compact summary below is posted. Pure and
|
|
4
|
+
// worker-agnostic so it is unit-testable without a Worker.
|
|
5
|
+
//
|
|
6
|
+
// Uses computeCoreTimeSeries in coreCount mode (O(n log n)) to derive the
|
|
7
|
+
// busy-core histogram; the time-bucket mode is intentionally NOT used here
|
|
8
|
+
// because it is O(buckets × segments) and a whole run can hold millions of
|
|
9
|
+
// tasks (see core-time-series.js PERF note).
|
|
10
|
+
|
|
11
|
+
import { FIELDS } from './stage-quantiles.js';
|
|
12
|
+
import { computeCoreTimeSeries } from './core-time-series.js';
|
|
13
|
+
|
|
14
|
+
export function computeRunAggregates(taskStore )
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
{
|
|
20
|
+
const intervals = [];
|
|
21
|
+
const perStage = {};
|
|
22
|
+
for (const [stageId, arr] of taskStore) {
|
|
23
|
+
const taskCount = arr.length / FIELDS.STRIDE;
|
|
24
|
+
let totalTaskDurationSum = 0;
|
|
25
|
+
for (let i = 0; i < taskCount; i++) {
|
|
26
|
+
const base = i * FIELDS.STRIDE;
|
|
27
|
+
const launch = arr[base + FIELDS.LAUNCH_TIME];
|
|
28
|
+
const finish = arr[base + FIELDS.FINISH_TIME];
|
|
29
|
+
totalTaskDurationSum += arr[base + FIELDS.DURATION];
|
|
30
|
+
if (finish > launch) intervals.push({ launch, finish });
|
|
31
|
+
}
|
|
32
|
+
perStage[stageId] = { totalTaskDurationSum, taskCount };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// bucketBy: 'coreCount' always yields the coreCount branch of the union;
|
|
36
|
+
// narrow explicitly since the callee's return type doesn't correlate to
|
|
37
|
+
// the literal `bucketBy` argument.
|
|
38
|
+
const { histogram } = computeCoreTimeSeries(intervals, { bucketBy: 'coreCount' }) ;
|
|
39
|
+
let busyCoreMs = 0;
|
|
40
|
+
for (let k = 0; k < histogram.length; k++) busyCoreMs += k * histogram[k];
|
|
41
|
+
const peakConcurrentCores = histogram.length > 0 ? histogram.length - 1 : 0;
|
|
42
|
+
|
|
43
|
+
return { coreHistogram: histogram, busyCoreMs, peakConcurrentCores, perStage };
|
|
44
|
+
}
|