sparkforensics-cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/bin/sparkforensics-analyze.mjs +288 -0
  2. package/package.json +29 -0
  3. package/vendor-core/analyzer.js +167 -0
  4. package/vendor-core/assert-never.js +3 -0
  5. package/vendor-core/cli/budgets.js +203 -0
  6. package/vendor-core/cli/collect-run.js +107 -0
  7. package/vendor-core/core-count.js +68 -0
  8. package/vendor-core/core-locality-ratio.js +54 -0
  9. package/vendor-core/core-time-series.js +92 -0
  10. package/vendor-core/core-usage-locality.js +70 -0
  11. package/vendor-core/detectors.js +1989 -0
  12. package/vendor-core/docs-config.js +72 -0
  13. package/vendor-core/docs-site-config.js +23 -0
  14. package/vendor-core/efficiency-model.js +62 -0
  15. package/vendor-core/etl-phases.js +28 -0
  16. package/vendor-core/event-handlers.js +906 -0
  17. package/vendor-core/event-schemas.js +405 -0
  18. package/vendor-core/evidence-availability.js +121 -0
  19. package/vendor-core/evidence-report.js +459 -0
  20. package/vendor-core/finding-action-label.js +97 -0
  21. package/vendor-core/finding-filter-predicate.js +38 -0
  22. package/vendor-core/format-utils.js +167 -0
  23. package/vendor-core/impact-band.js +50 -0
  24. package/vendor-core/impact-estimator.js +428 -0
  25. package/vendor-core/ingest.js +139 -0
  26. package/vendor-core/job-groups.js +30 -0
  27. package/vendor-core/load-vendored.js +24 -0
  28. package/vendor-core/lz4-block.js +135 -0
  29. package/vendor-core/mcp-error.js +3 -0
  30. package/vendor-core/mcp-server-factory.js +115 -0
  31. package/vendor-core/mcp-tools.js +331 -0
  32. package/vendor-core/model-assembler.js +76 -0
  33. package/vendor-core/occupancy.js +202 -0
  34. package/vendor-core/parser-worker.js +249 -0
  35. package/vendor-core/plan-dot.js +25 -0
  36. package/vendor-core/plan-duration-attribution.js +185 -0
  37. package/vendor-core/plan-graph-model.js +171 -0
  38. package/vendor-core/plan-node-detail.js +159 -0
  39. package/vendor-core/plan-summary.js +233 -0
  40. package/vendor-core/plan-tree-walk.js +29 -0
  41. package/vendor-core/proxy.js +157 -0
  42. package/vendor-core/recommendation-rollup.js +197 -0
  43. package/vendor-core/redact.js +175 -0
  44. package/vendor-core/rolling-log-reassembly.js +52 -0
  45. package/vendor-core/run-aggregates.js +44 -0
  46. package/vendor-core/run-comparison.js +458 -0
  47. package/vendor-core/scaling-sim.js +73 -0
  48. package/vendor-core/session-snapshot.js +79 -0
  49. package/vendor-core/shs-fetch.js +196 -0
  50. package/vendor-core/shs-load.js +121 -0
  51. package/vendor-core/shs-request.js +101 -0
  52. package/vendor-core/shs-schemas.js +13 -0
  53. package/vendor-core/snappy-block.js +140 -0
  54. package/vendor-core/stage-quantiles.js +199 -0
  55. package/vendor-core/threshold-summary.js +35 -0
  56. package/vendor-core/types.js +286 -0
  57. package/vendor-core/vendor/fflate.js +2695 -0
  58. package/vendor-core/vendor/fzstd.js +768 -0
  59. package/vendor-core/wall-clock.js +36 -0
  60. package/vendor-core/wasted-core-hours.js +68 -0
@@ -0,0 +1,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
+ }