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,405 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// sparkPlanInfo: iterative (non-recursive-schema) tree parser
|
|
5
|
+
//
|
|
6
|
+
// Validates the raw, unbounded-depth `sparkPlanInfo` tree carried on
|
|
7
|
+
// SparkListenerSQLExecutionStart/End events (consumed today by
|
|
8
|
+
// `resolvePlanTree` in event-handlers.js:233-273). Per the spec, this must
|
|
9
|
+
// NEVER be validated with z.lazy(): it uses an explicit heap-allocated stack,
|
|
10
|
+
// the same iterative-over-recursive approach src/plan-tree-walk.js's
|
|
11
|
+
// walkPlanTree already uses for the *resolved* tree (that one walks an
|
|
12
|
+
// already-built tree; this one builds one from raw JSON).
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
// `resolvePlanTree` (event-handlers.ts) reads `m.accumulatorId` to look up
|
|
16
|
+
// the accumulator's live value in accumMap; this field was missing from the
|
|
17
|
+
// original (Task 6) schema, so it was silently stripped by object-schema
|
|
18
|
+
// validation before resolvePlanTree ever saw it, breaking metric resolution
|
|
19
|
+
// for every plan node once real events start flowing through
|
|
20
|
+
// SparkEventSchema.safeParse. Declared optional (not required) to keep
|
|
21
|
+
// accepting tests/event-schemas.test.js's existing 'value'-only fixture;
|
|
22
|
+
// resolvePlanTree already tolerates a missing accumulatorId (accumMap.has
|
|
23
|
+
// just resolves false, the metric contributes no value).
|
|
24
|
+
const SparkPlanMetricSchema = z.object({
|
|
25
|
+
name: z.string(),
|
|
26
|
+
accumulatorId: z.number().optional(),
|
|
27
|
+
value: z.union([z.string(), z.number()]).optional(),
|
|
28
|
+
metricType: z.string().optional(),
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
// Shallow shape only. `children` is intentionally z.array(z.unknown()) here:
|
|
32
|
+
// each child gets its own shallow parse inside the iterative walk below, so
|
|
33
|
+
// this schema never recurses into itself and zod never sees the full tree
|
|
34
|
+
// depth in one call.
|
|
35
|
+
//
|
|
36
|
+
// `metadata` (file-scan nodes carry Location/Format/ReadSchema/PushedFilters/
|
|
37
|
+
// PartitionFilters/DataFilters/Batched here) was missing from the original
|
|
38
|
+
// schema, so it was silently stripped before ever reaching the posted `sql`
|
|
39
|
+
// message's `sparkPlanInfo` (same data-loss class as the accumulatorId gap
|
|
40
|
+
// above). Nothing reads it yet, but it's real data crossing the worker
|
|
41
|
+
// boundary, so it's kept rather than dropped.
|
|
42
|
+
const SparkPlanInfoNodeSchema = z.object({
|
|
43
|
+
nodeName: z.string(),
|
|
44
|
+
simpleString: z.string().optional(),
|
|
45
|
+
metrics: z.array(SparkPlanMetricSchema).optional(),
|
|
46
|
+
metadata: z.record(z.string(), z.unknown()).optional(),
|
|
47
|
+
children: z.array(z.unknown()),
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
const MAX_PLAN_DEPTH = 500;
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
export function parseSparkPlanInfoTree(raw ) {
|
|
68
|
+
const rootShallow = SparkPlanInfoNodeSchema.parse(raw);
|
|
69
|
+
const rootFrame = { shallow: rootShallow, children: [], pendingRaw: [...rootShallow.children], depth: 0 };
|
|
70
|
+
const stack = [rootFrame];
|
|
71
|
+
const parentOf = new Map ();
|
|
72
|
+
|
|
73
|
+
while (stack.length > 0) {
|
|
74
|
+
const top = stack[stack.length - 1];
|
|
75
|
+
if (top.pendingRaw.length === 0) {
|
|
76
|
+
stack.pop();
|
|
77
|
+
const built = { ...top.shallow, children: top.children };
|
|
78
|
+
const parent = parentOf.get(top);
|
|
79
|
+
if (parent) {
|
|
80
|
+
parent.children.push(built);
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
return built;
|
|
84
|
+
}
|
|
85
|
+
if (top.depth > MAX_PLAN_DEPTH) {
|
|
86
|
+
throw new Error(`sparkPlanInfo tree exceeds max depth of ${MAX_PLAN_DEPTH}`);
|
|
87
|
+
}
|
|
88
|
+
const nextRaw = top.pendingRaw.shift();
|
|
89
|
+
const shallow = SparkPlanInfoNodeSchema.parse(nextRaw);
|
|
90
|
+
const frame = { shallow, children: [], pendingRaw: [...shallow.children], depth: top.depth + 1 };
|
|
91
|
+
parentOf.set(frame, top);
|
|
92
|
+
stack.push(frame);
|
|
93
|
+
}
|
|
94
|
+
throw new Error('unreachable: sparkPlanInfo tree stack exhausted without resolving root');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// A zod field schema wrapping parseSparkPlanInfoTree, for use inside the
|
|
98
|
+
// SQLExecutionStart event schema. Nullable/optional to mirror
|
|
99
|
+
// `event.sparkPlanInfo ?? null` in startSqlExecution (event-handlers.js:448).
|
|
100
|
+
// Deliberately NOT z.lazy(): defers to the iterative parser above, so the
|
|
101
|
+
// full tree depth is still never handed to zod's own recursion.
|
|
102
|
+
const SparkPlanInfoFieldSchema = z
|
|
103
|
+
.unknown()
|
|
104
|
+
.nullable()
|
|
105
|
+
.optional()
|
|
106
|
+
.transform((val, ctx) => {
|
|
107
|
+
if (val == null) return null;
|
|
108
|
+
try {
|
|
109
|
+
return parseSparkPlanInfoTree(val);
|
|
110
|
+
} catch (err) {
|
|
111
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: err instanceof Error ? err.message : String(err) });
|
|
112
|
+
return z.NEVER;
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
// Six fully-specified event schemas (verbatim per task-6-brief.md Step 3;
|
|
118
|
+
// backed by exact quoted source cited in each comment)
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
// event-handlers.js:516-518 (`SparkListenerLogStart` case, inline: sets
|
|
122
|
+
// state.pendingSparkVersion)
|
|
123
|
+
export const LogStartEventSchema = z.object({
|
|
124
|
+
Event: z.literal('SparkListenerLogStart'),
|
|
125
|
+
'Spark Version': z.string().optional(),
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// event-handlers.js:293-306 (startApplication)
|
|
129
|
+
export const ApplicationStartEventSchema = z.object({
|
|
130
|
+
Event: z.literal('SparkListenerApplicationStart'),
|
|
131
|
+
'App ID': z.string().optional(),
|
|
132
|
+
'App Name': z.string().optional(),
|
|
133
|
+
Timestamp: z.number().optional(),
|
|
134
|
+
'Spark Version': z.string().optional(),
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
// event-handlers.js:308-323 (updateEnvironment): 'Spark Properties' is fed
|
|
138
|
+
// straight into normalizeSparkProperties(props), which already accepts
|
|
139
|
+
// object | array | null (src/format-utils... no, src/event-handlers.js:53-62)
|
|
140
|
+
//
|
|
141
|
+
// Property VALUES were previously required to be z.string(), but
|
|
142
|
+
// normalizeSparkProperties (event-handlers.ts:211-226) just spread each
|
|
143
|
+
// value through (`{ ...(props ?? {}) }` / `map[pair[0]] = pair[1]`) with no
|
|
144
|
+
// requirement that it already be a string, so a real Spark config carrying a
|
|
145
|
+
// non-string-looking JSON value (a bare number or boolean) was rejecting the
|
|
146
|
+
// entire EnvironmentUpdate event instead of parsing it. Widened to the
|
|
147
|
+
// realistic set of raw JSON scalar types; normalizeSparkProperties now
|
|
148
|
+
// coerces each value to a string at the boundary so its `Record<string,
|
|
149
|
+
// string>` return contract (and every downstream reader of `app.config`)
|
|
150
|
+
// is unaffected.
|
|
151
|
+
const SparkPropertyValueSchema = z.union([z.string(), z.number(), z.boolean()]);
|
|
152
|
+
export const EnvironmentUpdateEventSchema = z.object({
|
|
153
|
+
Event: z.literal('SparkListenerEnvironmentUpdate'),
|
|
154
|
+
'Spark Properties': z.union([
|
|
155
|
+
z.record(z.string(), SparkPropertyValueSchema),
|
|
156
|
+
z.array(z.tuple([z.string(), SparkPropertyValueSchema])),
|
|
157
|
+
z.null(),
|
|
158
|
+
]).optional(),
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// event-handlers.js:526-532 (inline ApplicationEnd case)
|
|
162
|
+
export const ApplicationEndEventSchema = z.object({
|
|
163
|
+
Event: z.literal('SparkListenerApplicationEnd'),
|
|
164
|
+
Timestamp: z.number().optional(),
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
// event-handlers.js:490-502 (addExecutor). `Resource Profile Id` is read from
|
|
168
|
+
// BOTH inside 'Executor Info' (the real Spark placement) and top-level (a
|
|
169
|
+
// fallback for a variant that hoists it) via `event['Executor Info']?.[...] ??
|
|
170
|
+
// event['Resource Profile Id']`, so both locations must be declared or the
|
|
171
|
+
// nested (primary) one is silently stripped by object-schema validation.
|
|
172
|
+
// `Timestamp` is read as `event['Timestamp']` with no defensive operator, so
|
|
173
|
+
// per the optionality rule it's required, not guessed-optional (it also
|
|
174
|
+
// backs ExecutorAddedEvent.timestamp: number, a non-optional field).
|
|
175
|
+
export const ExecutorAddedEventSchema = z.object({
|
|
176
|
+
Event: z.literal('SparkListenerExecutorAdded'),
|
|
177
|
+
Timestamp: z.number(),
|
|
178
|
+
'Executor ID': z.string(),
|
|
179
|
+
'Executor Info': z.object({
|
|
180
|
+
Host: z.string().optional(),
|
|
181
|
+
'Total Cores': z.number().optional(),
|
|
182
|
+
'Resource Profile Id': z.number().optional(),
|
|
183
|
+
}).optional(),
|
|
184
|
+
'Resource Profile Id': z.number().optional(),
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
// event-handlers.js:504-512 (removeExecutor). `Timestamp` is read as
|
|
188
|
+
// `event['Timestamp']` with no defensive operator, so per the optionality
|
|
189
|
+
// rule it's required (backs ExecutorRemovedEvent.timestamp: number).
|
|
190
|
+
export const ExecutorRemovedEventSchema = z.object({
|
|
191
|
+
Event: z.literal('SparkListenerExecutorRemoved'),
|
|
192
|
+
Timestamp: z.number(),
|
|
193
|
+
'Executor ID': z.string(),
|
|
194
|
+
'Removed Reason': z.string().optional(),
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
// ---------------------------------------------------------------------------
|
|
198
|
+
// Nine schemas transcribed from their handlers (task-6-brief.md Step 4). See
|
|
199
|
+
// task-6-report.md for the exact field-by-field derivation from each handler.
|
|
200
|
+
// ---------------------------------------------------------------------------
|
|
201
|
+
|
|
202
|
+
// event-handlers.js:325-345 (startJob). 'spark.sql.execution.id' is read via
|
|
203
|
+
// `event['Properties']?.['spark.sql.execution.id']` then
|
|
204
|
+
// `parseInt(sqlExecIdStr, 10)` (startJob, event-handlers.ts:498-501):
|
|
205
|
+
// parseInt coerces its argument to a string internally, so it tolerates the
|
|
206
|
+
// raw value already being a number just as well as a string; requiring
|
|
207
|
+
// z.string() here rejected the whole JobStart event for a log variant that
|
|
208
|
+
// emits this property as a JSON number instead of a numeric string.
|
|
209
|
+
export const JobStartEventSchema = z.object({
|
|
210
|
+
Event: z.literal('SparkListenerJobStart'),
|
|
211
|
+
'Job ID': z.number().optional(),
|
|
212
|
+
'Submission Time': z.number().optional(),
|
|
213
|
+
'Stage IDs': z.array(z.number()).optional(),
|
|
214
|
+
Properties: z.object({
|
|
215
|
+
'spark.sql.execution.id': z.union([z.string(), z.number()]).optional(),
|
|
216
|
+
}).optional(),
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
// event-handlers.js:347-360 (endJob)
|
|
220
|
+
export const JobEndEventSchema = z.object({
|
|
221
|
+
Event: z.literal('SparkListenerJobEnd'),
|
|
222
|
+
'Job ID': z.number(),
|
|
223
|
+
'Completion Time': z.number().optional(),
|
|
224
|
+
'Job Result': z.object({
|
|
225
|
+
Result: z.string().optional(),
|
|
226
|
+
Exception: z.object({
|
|
227
|
+
Message: z.string().optional(),
|
|
228
|
+
}).optional(),
|
|
229
|
+
}).optional(),
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
// event-handlers.js:362-389 (submitStage) + mergeStageRddInfo
|
|
233
|
+
// (event-handlers.js:391-422, called from submitStage with the same 'Stage
|
|
234
|
+
// Info' object, so its 'RDD Info' reads are part of this event's shape too)
|
|
235
|
+
const RddInfoSchema = z.object({
|
|
236
|
+
'RDD ID': z.number().optional(),
|
|
237
|
+
Name: z.string().optional(),
|
|
238
|
+
Callsite: z.string().optional(),
|
|
239
|
+
'Storage Level': z.object({
|
|
240
|
+
'Use Disk': z.boolean().optional(),
|
|
241
|
+
'Use Memory': z.boolean().optional(),
|
|
242
|
+
Deserialized: z.boolean().optional(),
|
|
243
|
+
Replication: z.number().optional(),
|
|
244
|
+
}).optional(),
|
|
245
|
+
'Number of Partitions': z.number().optional(),
|
|
246
|
+
// 'Number of Cached Partitions' / 'Memory Size' / 'Disk Size' are read via
|
|
247
|
+
// `rdd[field] || prev?.field || 0` (mergeStageRddInfo, event-handlers.ts:
|
|
248
|
+
// 594-596): a `||` fallback is tolerant of the key being absent
|
|
249
|
+
// (undefined || ... just falls through to the next fallback), so a real
|
|
250
|
+
// event-log variant that omits one of these keys must still parse.
|
|
251
|
+
'Number of Cached Partitions': z.number().optional(),
|
|
252
|
+
'Memory Size': z.number().optional(),
|
|
253
|
+
'Disk Size': z.number().optional(),
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
export const StageSubmittedEventSchema = z.object({
|
|
257
|
+
Event: z.literal('SparkListenerStageSubmitted'),
|
|
258
|
+
'Stage Info': z.object({
|
|
259
|
+
'Stage ID': z.number(),
|
|
260
|
+
'Stage Name': z.string().optional(),
|
|
261
|
+
Details: z.string().optional(),
|
|
262
|
+
'Submission Time': z.number().optional(),
|
|
263
|
+
'Parent IDs': z.array(z.number()).optional(),
|
|
264
|
+
'RDD Info': z.array(RddInfoSchema).optional(),
|
|
265
|
+
}),
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
// event-handlers.js:543-551 (inline StageCompleted case)
|
|
269
|
+
export const StageCompletedEventSchema = z.object({
|
|
270
|
+
Event: z.literal('SparkListenerStageCompleted'),
|
|
271
|
+
'Stage Info': z.object({
|
|
272
|
+
'Stage ID': z.number(),
|
|
273
|
+
'Completion Time': z.number().optional(),
|
|
274
|
+
'Failure Reason': z.string().optional(),
|
|
275
|
+
}),
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
// event-handlers.js:431-444 (recordStageExecutorMetrics)
|
|
279
|
+
export const StageExecutorMetricsEventSchema = z.object({
|
|
280
|
+
Event: z.literal('SparkListenerStageExecutorMetrics'),
|
|
281
|
+
'Stage ID': z.number(),
|
|
282
|
+
'Executor ID': z.string(),
|
|
283
|
+
'Executor Metrics': z.record(z.string(), z.number()).optional(),
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
const MAX_ACCUMULABLES_PER_TASK = 10_000;
|
|
287
|
+
|
|
288
|
+
// event-handlers.js:147-231 (accumulateTask)
|
|
289
|
+
export const TaskEndEventSchema = z.object({
|
|
290
|
+
Event: z.literal('SparkListenerTaskEnd'),
|
|
291
|
+
'Stage ID': z.number(),
|
|
292
|
+
'Stage Attempt ID': z.number().optional(),
|
|
293
|
+
'Task End Reason': z.object({
|
|
294
|
+
Reason: z.string().optional(),
|
|
295
|
+
}).optional(),
|
|
296
|
+
'Task Info': z.object({
|
|
297
|
+
'Launch Time': z.number().optional(),
|
|
298
|
+
'Finish Time': z.number().optional(),
|
|
299
|
+
// Failed/Killed are read via `info['Failed'] || info['Killed']`
|
|
300
|
+
// (accumulateTask, event-handlers.ts:340): a `||` fallback is tolerant
|
|
301
|
+
// of either key being absent (undefined || undefined is falsy, no
|
|
302
|
+
// crash), so real event-log variants that omit these keys must still
|
|
303
|
+
// parse rather than have the whole TaskEnd event rejected.
|
|
304
|
+
Failed: z.boolean().optional(),
|
|
305
|
+
Killed: z.boolean().optional(),
|
|
306
|
+
// Speculative is read via `info['Speculative'] === true`
|
|
307
|
+
// (accumulateTask, event-handlers.ts:347): a strict-equality check is
|
|
308
|
+
// also tolerant of the key being absent (undefined === true is just
|
|
309
|
+
// false), so this is optional too.
|
|
310
|
+
Speculative: z.boolean().optional(),
|
|
311
|
+
Host: z.string().optional(),
|
|
312
|
+
'Executor ID': z.string().optional(),
|
|
313
|
+
Locality: z.string().optional(),
|
|
314
|
+
Index: z.number().optional(),
|
|
315
|
+
// Bounded well above any real plan's per-task metric count (see
|
|
316
|
+
// MAX_PLAN_DEPTH above for the same defensive intent): caps how much a
|
|
317
|
+
// single crafted TaskEnd can grow event-handlers.ts's taskAccumStages,
|
|
318
|
+
// which is never pruned for the life of the parse.
|
|
319
|
+
Accumulables: z.array(z.object({
|
|
320
|
+
ID: z.number(),
|
|
321
|
+
Update: z.union([z.string(), z.number()]).optional(),
|
|
322
|
+
Value: z.union([z.string(), z.number()]).optional(),
|
|
323
|
+
})).max(MAX_ACCUMULABLES_PER_TASK).optional(),
|
|
324
|
+
}).optional(),
|
|
325
|
+
'Task Metrics': z.object({
|
|
326
|
+
'Peak Execution Memory': z.number().optional(),
|
|
327
|
+
'JVM GC Time': z.number().optional(),
|
|
328
|
+
'Memory Bytes Spilled': z.number().optional(),
|
|
329
|
+
'Disk Bytes Spilled': z.number().optional(),
|
|
330
|
+
'Executor Run Time': z.number().optional(),
|
|
331
|
+
'Executor CPU Time': z.number().optional(),
|
|
332
|
+
'Shuffle Read Metrics': z.object({
|
|
333
|
+
'Remote Bytes Read': z.number().optional(),
|
|
334
|
+
'Local Bytes Read': z.number().optional(),
|
|
335
|
+
'Fetch Wait Time': z.number().optional(),
|
|
336
|
+
}).optional(),
|
|
337
|
+
'Shuffle Write Metrics': z.object({
|
|
338
|
+
'Shuffle Bytes Written': z.number().optional(),
|
|
339
|
+
}).optional(),
|
|
340
|
+
'Input Metrics': z.object({
|
|
341
|
+
'Bytes Read': z.number().optional(),
|
|
342
|
+
}).optional(),
|
|
343
|
+
'Output Metrics': z.object({
|
|
344
|
+
'Bytes Written': z.number().optional(),
|
|
345
|
+
}).optional(),
|
|
346
|
+
}).optional(),
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
// event-handlers.js:446-460 (startSqlExecution)
|
|
350
|
+
export const SqlExecutionStartEventSchema = z.object({
|
|
351
|
+
Event: z.literal('org.apache.spark.sql.execution.ui.SparkListenerSQLExecutionStart'),
|
|
352
|
+
executionId: z.number(),
|
|
353
|
+
description: z.string().optional(),
|
|
354
|
+
time: z.number(),
|
|
355
|
+
physicalPlanDescription: z.string().optional(),
|
|
356
|
+
sparkPlanInfo: SparkPlanInfoFieldSchema,
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
// event-handlers.js (applyAdaptiveExecutionUpdate): AQE re-plans mid-query
|
|
360
|
+
// and re-emits sparkPlanInfo for the same executionId; last write wins.
|
|
361
|
+
export const SqlAdaptiveExecutionUpdateEventSchema = z.object({
|
|
362
|
+
Event: z.literal('org.apache.spark.sql.execution.ui.SparkListenerSQLAdaptiveExecutionUpdate'),
|
|
363
|
+
executionId: z.number(),
|
|
364
|
+
physicalPlanDescription: z.string().optional(),
|
|
365
|
+
sparkPlanInfo: SparkPlanInfoFieldSchema,
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
// event-handlers.js:462-478 (endSqlExecution)
|
|
369
|
+
export const SqlExecutionEndEventSchema = z.object({
|
|
370
|
+
Event: z.literal('org.apache.spark.sql.execution.ui.SparkListenerSQLExecutionEnd'),
|
|
371
|
+
executionId: z.number(),
|
|
372
|
+
time: z.number(),
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
// event-handlers.js:480-488 (applyDriverAccumUpdates)
|
|
376
|
+
export const DriverAccumUpdatesEventSchema = z.object({
|
|
377
|
+
Event: z.literal('org.apache.spark.sql.execution.ui.SparkListenerDriverAccumUpdates'),
|
|
378
|
+
executionId: z.number(),
|
|
379
|
+
accumUpdates: z.array(z.tuple([z.number(), z.number()])),
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
// ---------------------------------------------------------------------------
|
|
383
|
+
// Discriminated union: exactly 16 entries, one per `case` in processEvent's
|
|
384
|
+
// switch (event-handlers.js:514-577).
|
|
385
|
+
// ---------------------------------------------------------------------------
|
|
386
|
+
|
|
387
|
+
export const SparkEventSchema = z.discriminatedUnion('Event', [
|
|
388
|
+
LogStartEventSchema,
|
|
389
|
+
ApplicationStartEventSchema,
|
|
390
|
+
EnvironmentUpdateEventSchema,
|
|
391
|
+
ApplicationEndEventSchema,
|
|
392
|
+
JobStartEventSchema,
|
|
393
|
+
JobEndEventSchema,
|
|
394
|
+
StageSubmittedEventSchema,
|
|
395
|
+
StageCompletedEventSchema,
|
|
396
|
+
StageExecutorMetricsEventSchema,
|
|
397
|
+
TaskEndEventSchema,
|
|
398
|
+
SqlExecutionStartEventSchema,
|
|
399
|
+
SqlAdaptiveExecutionUpdateEventSchema,
|
|
400
|
+
SqlExecutionEndEventSchema,
|
|
401
|
+
DriverAccumUpdatesEventSchema,
|
|
402
|
+
ExecutorAddedEventSchema,
|
|
403
|
+
ExecutorRemovedEventSchema,
|
|
404
|
+
]);
|
|
405
|
+
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
|
|
2
|
+
|
|
3
|
+
// Not annotated `: number` on purpose: `const X = 1` infers the literal type
|
|
4
|
+
// `1`, which is assignable both wherever `number` is expected (e.g. equality
|
|
5
|
+
// checks below) and wherever the `EvidenceAvailability.schemaVersion: 1`
|
|
6
|
+
// literal field is expected (the `return` at the bottom of this file);
|
|
7
|
+
// widening it to `number` would break that assignment.
|
|
8
|
+
export const EVIDENCE_AVAILABILITY_SCHEMA_VERSION = 1;
|
|
9
|
+
|
|
10
|
+
const SUMMARIES = {
|
|
11
|
+
observed: 'Observed in this event log.',
|
|
12
|
+
explicitlyDisabled: 'Explicitly disabled in this event log.',
|
|
13
|
+
noObservedExecutorMetrics: 'Not emitted by this event log.',
|
|
14
|
+
noObservedStageSubmission: 'Not emitted by this event log.',
|
|
15
|
+
noRddStorageSnapshot: 'Stages were submitted without RDD storage snapshots.',
|
|
16
|
+
noResolvedSqlPlan: 'Not emitted by this event log.',
|
|
17
|
+
noSqlExecution: 'Not applicable to this event log.',
|
|
18
|
+
noEnvironmentUpdate: 'Not emitted by this event log.',
|
|
19
|
+
noTaskRecords: 'Not emitted by this event log.',
|
|
20
|
+
noUsableCoreTimeAggregate: 'No usable aggregate was emitted.',
|
|
21
|
+
outsideEventLogScope: 'Available outside local event-log scope.',
|
|
22
|
+
parseIncomplete: 'Cannot determine from an incomplete parse.',
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
function observed(key , eventType , count ) {
|
|
26
|
+
return { key, state: 'present', reasonCode: 'observed', summary: SUMMARIES.observed, evidence: { eventType, count } };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function entry(key , state , reasonCode ) {
|
|
30
|
+
return { key, state, reasonCode, summary: SUMMARIES[reasonCode] };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function count(inputs , key ) {
|
|
34
|
+
const value = inputs[key];
|
|
35
|
+
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : 0;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** True when the serialized value is a ledger this build understands. Fails
|
|
39
|
+
* closed on any unknown `schemaVersion` so a future, incompatible ledger is
|
|
40
|
+
* never trusted as V1 data; used at read/restore time, not just write time. */
|
|
41
|
+
export function isSupportedEvidenceAvailability(value ) {
|
|
42
|
+
return value != null && (value ).schemaVersion === EVIDENCE_AVAILABILITY_SCHEMA_VERSION;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function isPositiveFinite(value ) {
|
|
46
|
+
return typeof value === 'number' && Number.isFinite(value) && value > 0;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** A stage contributes usable core-time evidence only when it both ran tasks
|
|
50
|
+
* and produced a positive duration sum: a degenerate aggregate (every task
|
|
51
|
+
* with `finish <= launch`) carries `totalTaskDurationSum <= 0` and proves
|
|
52
|
+
* nothing, so it must not report `present`. Shared with ScalingSim so the
|
|
53
|
+
* two consumers cannot drift. */
|
|
54
|
+
export function hasUsableRunAggregates(runAggregates ) {
|
|
55
|
+
const perStage = (runAggregates )?.perStage;
|
|
56
|
+
return perStage != null
|
|
57
|
+
&& typeof perStage === 'object'
|
|
58
|
+
&& !Array.isArray(perStage)
|
|
59
|
+
&& Object.values(perStage ).some(
|
|
60
|
+
(stage) => isPositiveFinite(stage?.taskCount) && isPositiveFinite(stage?.totalTaskDurationSum),
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Matches the parser's config normalization (`.toLowerCase() === 'true'`),
|
|
65
|
+
* so a real boolean or mixed-case `'False'` is handled identically. */
|
|
66
|
+
function configIs(value , expected ) {
|
|
67
|
+
return value != null && String(value).toLowerCase() === expected;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function absent(key , reasonCode , trustworthy ) {
|
|
71
|
+
return trustworthy ? entry(key, reasonCode === 'noSqlExecution' ? 'notApplicable' : 'notEmitted', reasonCode) : entry(key, 'unknown', 'parseIncomplete');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function deriveEvidenceAvailability(appModel , { skippedLines = 0 } = {}) {
|
|
75
|
+
const app = appModel?.app;
|
|
76
|
+
const inputs = app?.evidenceInputs ?? {};
|
|
77
|
+
const trustworthy = skippedLines === 0 && count(inputs, 'applicationEnds') > 0;
|
|
78
|
+
const metricRows = count(inputs, 'executorMetricRows');
|
|
79
|
+
const submissions = count(inputs, 'stageSubmissions');
|
|
80
|
+
const snapshots = count(inputs, 'rddStorageSnapshots');
|
|
81
|
+
const plans = count(inputs, 'resolvedSqlPlans');
|
|
82
|
+
const executions = count(inputs, 'sqlExecutions');
|
|
83
|
+
const environments = count(inputs, 'environmentUpdates');
|
|
84
|
+
const taskRecords = count(inputs, 'taskRecords');
|
|
85
|
+
|
|
86
|
+
const executorMetrics = metricRows > 0
|
|
87
|
+
? observed('executorMetrics', 'executorMetricRows', metricRows)
|
|
88
|
+
: configIs(app?.config?.['spark.eventLog.logStageExecutorMetrics'], 'false')
|
|
89
|
+
? entry('executorMetrics', 'disabled', 'explicitlyDisabled')
|
|
90
|
+
: absent('executorMetrics', 'noObservedExecutorMetrics', trustworthy);
|
|
91
|
+
const rddStorageSnapshots = snapshots > 0
|
|
92
|
+
? observed('rddStorageSnapshots', 'rddStorageSnapshots', snapshots)
|
|
93
|
+
: absent('rddStorageSnapshots', submissions === 0 ? 'noObservedStageSubmission' : 'noRddStorageSnapshot', trustworthy);
|
|
94
|
+
const sqlPlan = plans > 0
|
|
95
|
+
? observed('sqlPlan', 'resolvedSqlPlans', plans)
|
|
96
|
+
: executions > 0 && trustworthy
|
|
97
|
+
? entry('sqlPlan', 'notEmitted', 'noResolvedSqlPlan')
|
|
98
|
+
: absent('sqlPlan', 'noSqlExecution', trustworthy);
|
|
99
|
+
const sparkConfiguration = environments > 0
|
|
100
|
+
? observed('sparkConfiguration', 'environmentUpdates', environments)
|
|
101
|
+
: absent('sparkConfiguration', 'noEnvironmentUpdate', trustworthy);
|
|
102
|
+
const taskCoreTime = taskRecords === 0
|
|
103
|
+
? absent('taskCoreTime', 'noTaskRecords', trustworthy)
|
|
104
|
+
: hasUsableRunAggregates(appModel?.runAggregates)
|
|
105
|
+
? observed('taskCoreTime', 'taskRecords', taskRecords)
|
|
106
|
+
: absent('taskCoreTime', 'noUsableCoreTimeAggregate', trustworthy);
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
schemaVersion: EVIDENCE_AVAILABILITY_SCHEMA_VERSION,
|
|
110
|
+
entries: [
|
|
111
|
+
executorMetrics,
|
|
112
|
+
rddStorageSnapshots,
|
|
113
|
+
sqlPlan,
|
|
114
|
+
sparkConfiguration,
|
|
115
|
+
taskCoreTime,
|
|
116
|
+
entry('infrastructureContext', 'outsideEventLog', 'outsideEventLogScope'),
|
|
117
|
+
entry('sourceContext', 'outsideEventLog', 'outsideEventLogScope'),
|
|
118
|
+
entry('costContext', 'outsideEventLog', 'outsideEventLogScope'),
|
|
119
|
+
],
|
|
120
|
+
};
|
|
121
|
+
}
|