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,159 @@
1
+ // Per-operator plan-node rendering helpers for the stage-detail modal's
2
+ // hierarchical plan tree. Ported near-verbatim from the legacy canvas widget
3
+ // (src/widgets/stage-detail.js, deleted at 1db5365): pure string-parsing /
4
+ // tree-walking logic, no DOM. Per AGENTS.md, plan-text parsing is
5
+ // best-effort: unparseable fragments are silently skipped, never thrown.
6
+
7
+ import { pathBasename, formatBytes, formatDuration } from './format-utils.js';
8
+ import { attributeStageDurationToPlan } from './plan-duration-attribution.js';
9
+ import { stageIdsForSqlExec } from './detectors.js';
10
+
11
+
12
+ const BOILERPLATE_PREFIXES = ['serializefromobject', 'deserializetoobject', 'mapelements',
13
+ 'mappartitions', 'inputadapter', 'columnartorow', 'rowtocolumnar', 'union'];
14
+
15
+ export function classifyNode(
16
+ name ,
17
+ ) {
18
+ const n = (name ?? '').toLowerCase();
19
+ if (n.startsWith('scan')) return 'scan';
20
+ if (n.includes('exchange')) return 'exchange';
21
+ if (n.includes('join')) return 'join';
22
+ if (n.includes('aggregate')) return 'aggregate';
23
+ if (n.startsWith('sort')) return 'sort';
24
+ if (n.startsWith('filter')) return 'filter';
25
+ if (n === 'adaptivesparkplan') return 'aqe';
26
+ if (BOILERPLATE_PREFIXES.some(b => n.startsWith(b)) || n.startsWith('wholestagecodegen')) return 'boilerplate';
27
+ return 'transform';
28
+ }
29
+
30
+ export function parseOperatorDetail(name , simpleString ) {
31
+ if (!simpleString || simpleString === name) return '';
32
+ const n = (name ?? '').toLowerCase();
33
+ const stripAliases = (s ) => s.replace(/#\d+/g, '');
34
+
35
+ if (BOILERPLATE_PREFIXES.some(b => n.startsWith(b))) return '';
36
+ if (n === 'adaptivesparkplan') return '';
37
+ if (n.startsWith('wholestagecodegen')) {
38
+ const m = name.match(/\((\d+)\)/);
39
+ return m ? `codegen #${m[1]}` : '';
40
+ }
41
+
42
+ if (n.includes('exchange')) {
43
+ const m = simpleString.match(/Exchange\s+(\w+partitioning)\(([^)]+)\)/i);
44
+ if (m) {
45
+ const type = m[1].toLowerCase().startsWith('hash') ? 'hash' : 'range';
46
+ const args = stripAliases(m[2]).split(',').map(s => s.trim());
47
+ const lastIsNum = /^\d+$/.test(args[args.length - 1]);
48
+ const partCount = lastIsNum ? args.pop() : null;
49
+ const keys = args.map(k =>
50
+ k.replace(/\s+ASC(\s+NULLS\s+\w+)?$/i, '↑')
51
+ .replace(/\s+DESC(\s+NULLS\s+\w+)?$/i, '↓').trim()
52
+ ).slice(0, 3);
53
+ return `${type}(${keys.join(', ')}${partCount ? `, ${partCount}` : ''})`;
54
+ }
55
+ const singleM = simpleString.match(/Exchange\s+(\w+)/);
56
+ return singleM ? singleM[1] : '';
57
+ }
58
+
59
+ if (n.startsWith('sort') && !n.includes('merge')) {
60
+ const m = simpleString.match(/Sort\s+\[([^\]]+)\]/);
61
+ if (m) {
62
+ return stripAliases(m[1]).split(',').map(s =>
63
+ s.trim()
64
+ .replace(/\s+ASC(\s+NULLS\s+\w+)?$/i, '↑')
65
+ .replace(/\s+DESC(\s+NULLS\s+\w+)?$/i, '↓')
66
+ ).slice(0, 4).join(', ');
67
+ }
68
+ }
69
+
70
+ if (n.includes('join')) {
71
+ const m = simpleString.match(/Join\s+\[([^\]]*)\][^,]*,\s*\[[^\]]*\][^,]*,\s*(\w+)/i);
72
+ if (m) {
73
+ const leftKey = stripAliases(m[1]).split(',')[0].trim();
74
+ return leftKey ? `${leftKey} ${m[2]}` : m[2];
75
+ }
76
+ }
77
+
78
+ if (n.includes('aggregate')) {
79
+ const funcM = simpleString.match(/functions=\[([^\]]+)\]/);
80
+ if (funcM) {
81
+ const funcs = [...new Set(
82
+ funcM[1].split(',').map(f => (f.trim().match(/^(\w+)\(/)?.[1] ?? '').replace(/^partial_/, ''))
83
+ )].filter(Boolean);
84
+ return funcs.slice(0, 5).join(', ') + (funcs.length > 5 ? ` +${funcs.length - 5}` : '');
85
+ }
86
+ }
87
+
88
+ if (n.startsWith('filter')) {
89
+ const m = simpleString.match(/Filter\s+(.+)/s);
90
+ if (m) {
91
+ const expr = stripAliases(m[1]).trim().replace(/^\((.+)\)$/s, '$1');
92
+ return expr.length > 70 ? expr.slice(0, 70) + '…' : expr;
93
+ }
94
+ }
95
+
96
+ if (n.startsWith('project')) {
97
+ const m = simpleString.match(/Project\s+\[([^\]]+)\]/);
98
+ if (m) {
99
+ const cols = stripAliases(m[1]).split(',').map(s => s.trim());
100
+ if (cols.length <= 3) return cols.join(', ');
101
+ return `${cols.length} columns`;
102
+ }
103
+ return '';
104
+ }
105
+
106
+ if (n.startsWith('scan')) {
107
+ if (n.includes('existingrdd')) {
108
+ const short = pathBasename(name);
109
+ if (short !== name) return short;
110
+ }
111
+ const fmtM = name.match(/Scan\s+(\S+)/);
112
+ return fmtM ? fmtM[1] : '';
113
+ }
114
+
115
+ const stripped = stripAliases(simpleString);
116
+ return stripped.length > 80 ? stripped.slice(0, 80) + '…' : stripped;
117
+ }
118
+
119
+ export function formatMetricValue(metric ) {
120
+ const { value, metricType } = metric;
121
+ if (typeof value === 'string') return value;
122
+ if (metricType === 'size') return formatBytes(value);
123
+ if (metricType === 'timing') return formatDuration(value);
124
+ if (metricType === 'nsTiming') return formatDuration(value / 1_000_000);
125
+ return value.toLocaleString('en-US');
126
+ }
127
+
128
+ export function getPrimaryMetric(
129
+ metrics ,
130
+ ) {
131
+ if (!metrics || metrics.length === 0) return '';
132
+ const priority = ['number of output rows', 'size of files read', 'spill size', 'duration'];
133
+ const found = priority.map(n => metrics.find(m => m.name === n)).find(Boolean) ?? metrics[0];
134
+ return formatMetricValue(found);
135
+ }
136
+
137
+ // Approximate per-node wall-time (dataflint-style segment attribution): see
138
+ // attributeStageDurationToPlan. Returns null when there's nothing to attribute
139
+ // (no plan tree, or the SQL execution doesn't carry a stageIds list).
140
+ export function buildDurationMap(
141
+ planTree ,
142
+ appModel ,
143
+ sqlExec ,
144
+ executionId ,
145
+ ) {
146
+ if (!planTree || !sqlExec) return null;
147
+ const stageIds = stageIdsForSqlExec(executionId, appModel.stages);
148
+ if (!stageIds.length) return null;
149
+ const stagesById = new Map ();
150
+ for (const id of stageIds) {
151
+ const s = appModel.stages.get(id);
152
+ if (s) stagesById.set(id, { submittedAt: s.submittedAt, completedAt: s.completedAt });
153
+ }
154
+ // attributeStageDurationToPlan still reads its 3rd arg's own `.stageIds`
155
+ // field internally (Task 2 didn't change that signature); override it
156
+ // with the derived array rather than the (always-empty, on real data)
157
+ // one already on `sqlExec`, without needing to change that function.
158
+ return attributeStageDurationToPlan(planTree, stagesById, { ...sqlExec, stageIds });
159
+ }
@@ -0,0 +1,233 @@
1
+ // Best-effort summary of a resolved plan tree (parser-worker `resolvePlanTree`).
2
+ // Per AGENTS.md the plan surface is best-effort: unparseable fragments are
3
+ // silently skipped, never surfaced as an error. This replaces the old regex
4
+ // `plan-extractor.js`: the single plan representation is the tree.
5
+
6
+
7
+ import { pathBasename } from './format-utils.js';
8
+ import { walkPlanTree } from './plan-tree-walk.js';
9
+
10
+
11
+
12
+
13
+
14
+
15
+
16
+
17
+
18
+
19
+
20
+
21
+
22
+
23
+
24
+ function cleanColRef(ref ) { return ref.trim().replace(/#\w+$/, ''); }
25
+
26
+ function parseColList(bracketed ) {
27
+ return bracketed.replace(/^\[|\]$/g, '').split(',').map(cleanColRef).filter(Boolean);
28
+ }
29
+
30
+ function splitTopLevel(s ) {
31
+ const parts = []; let depth = 0, start = 0;
32
+ for (let i = 0; i < s.length; i++) {
33
+ if (s[i] === '(') depth++;
34
+ else if (s[i] === ')') depth--;
35
+ else if (s[i] === ',' && depth === 0) { parts.push(s.slice(start, i).trim()); start = i + 1; }
36
+ }
37
+ parts.push(s.slice(start).trim());
38
+ return parts.filter(Boolean);
39
+ }
40
+
41
+ function pushCrossJoinWarning(result , label ) {
42
+ result.warnings.push({ type: 'crossJoin', detail: `${label} detected: this can cause severe data explosion.` });
43
+ }
44
+ function pushLongFilterWarning(result , len ) {
45
+ result.warnings.push({ type: 'longFilterCondition', detail: `Filter condition is ${len} characters: consider simplifying or pushing the filter down closer to the scan.` });
46
+ }
47
+
48
+ // Stable relation-identity key for a scan node: "<format>:<relation>" (e.g.
49
+ // "delta:mx.t_emp_whitelist", "parquet:business_prd.sales", "jdbc:dw.d_producto")
50
+ // or null when the node is internal Delta metadata / a non-scan / un-nameable.
51
+ // The single source of truth for scan identity, shared by `visitScan` (summary)
52
+ // and the `cachingOpportunity` detector so the regexes live in one place.
53
+ export function scanRelationId(name , detail ) {
54
+ // 1. Named catalog table, untruncated, from the nodeName (the key fix; the
55
+ // Location: path is truncated at ~100 chars and points at _delta_log for
56
+ // Delta tables). All catalog tables surface as `spark_catalog.<db>.<table>`.
57
+ let format = null, table = null;
58
+ const nameM = name.match(/^Scan\s+(parquet|orc|csv|json)\s+(spark_catalog\.\S+)$/i);
59
+ if (nameM) { format = nameM[1].toLowerCase(); table = nameM[2]; }
60
+ else {
61
+ const detM = detail.match(/FileScan\s+(parquet|orc|csv|json)\s+(spark_catalog\.[^[\s]+)\[/i);
62
+ if (detM) { format = detM[1].toLowerCase(); table = detM[2]; }
63
+ }
64
+ if (table) {
65
+ // A Delta data read keeps its Delta index marker; report format `delta`.
66
+ if (/PreparedDeltaFileIndex/i.test(detail)) format = 'delta';
67
+ return `${format}:${table.replace(/^spark_catalog\./, '')}`;
68
+ }
69
+
70
+ // 2. Internal Delta metadata (the _delta_log state scan and anonymous
71
+ // commit/checkpoint reads) → null. Location: is often truncated to `Del...`,
72
+ // so also key off DeltaLogFileIndex or the Delta-log action column signature.
73
+ // ponytail: column-signature heuristic; a future Spark that renames those
74
+ // action columns would leak a noise row; acceptable, verified clean on all 9 logs.
75
+ if (/_delta_log/.test(name) || /_delta_log/.test(detail) ||
76
+ /DeltaLogFileIndex/i.test(name + detail) ||
77
+ /checkpointMetadata#|sidecar#|commitInfo#/.test(name + detail)) {
78
+ return null;
79
+ }
80
+
81
+ // 3. Anonymous real file read (InMemoryFileIndex, no catalog name) →
82
+ // best-effort Location basename. Preserves temp/snapshot self-reuse; a
83
+ // truncated Location can still fragment these; inherent, unrecoverable.
84
+ const fileM = detail.match(/FileScan\s+(parquet|orc|csv|json)\b/i);
85
+ if (fileM) {
86
+ const locM = detail.match(/Location:[^[]*\[([^\]]+)\]/);
87
+ if (!locM) return null;
88
+ return `${fileM[1].toLowerCase()}:${pathBasename(locM[1].split(',')[0].trim())}`;
89
+ }
90
+
91
+ // 4. JDBC: the inner SQL's first real table, lowercased. null when the first
92
+ // post-FROM token is a subquery `(` or there is no single table (CTE/UNION).
93
+ if (/JDBCRelation/.test(detail)) {
94
+ const fromM = jdbcSql(detail).match(/\bFROM\s+(\(|[A-Za-z_][\w.]*)/i);
95
+ if (!fromM || fromM[1] === '(') return null;
96
+ return `jdbc:${fromM[1].toLowerCase()}`;
97
+ }
98
+
99
+ // 5. Non-scan / unrecognized.
100
+ return null;
101
+ }
102
+
103
+ // Extracts the inner SQL text from a JDBCRelation detail string (used for both
104
+ // the relation table name and the scan's `sql` summary field).
105
+ function jdbcSql(detail ) {
106
+ const jdbcM = detail.match(/JDBCRelation\(([\s\S]+?)\)\s*(?:SPARK_GEN_SUBQ_\d+\)?)?\s*\[numPartitions/);
107
+ const sqlM = detail.match(/JDBCRelation\(\(([\s\S]+?)\)\s*SPARK_GEN_SUBQ/);
108
+ return (sqlM ? sqlM[1] : (jdbcM ? jdbcM[1] : '')).replace(/\s+/g, ' ').trim();
109
+ }
110
+
111
+ function visitScan(name , detail , result ) {
112
+ // Identity (path + format) comes from the shared classifier; a null key means
113
+ // internal Delta metadata / an un-nameable read; no Sources bullet.
114
+ const rid = scanRelationId(name, detail);
115
+ if (!rid) return false;
116
+ const colon = rid.indexOf(':');
117
+ const format = rid.slice(0, colon);
118
+ const path = rid.slice(colon + 1);
119
+
120
+ if (format === 'jdbc') {
121
+ result.scans.push({ path, format, projectedColumns: [], pushedFilters: [], sql: jdbcSql(detail) });
122
+ return true;
123
+ }
124
+ // FileScan <fmt> [cols] ... PushedFilters: [...] (format may be `delta`).
125
+ const colsM = detail.match(/FileScan\s+\w+\s+(\[[^\]]*\])/i) ?? detail.match(/(\[[^\]]*\])/);
126
+ const filM = detail.match(/PushedFilters:\s*\[([^\]]*)\]/);
127
+ result.scans.push({
128
+ path,
129
+ format,
130
+ projectedColumns: colsM ? parseColList(colsM[1]) : [],
131
+ pushedFilters: filM && filM[1] ? filM[1].split(',').map(s => s.trim()).filter(Boolean) : [],
132
+ });
133
+ return true;
134
+ }
135
+
136
+ function visitJoin(_name , detail , result ) {
137
+ // <JoinOp> [leftKeys], [rightKeys], <JoinType>[, BuildSide]
138
+ const m = detail.match(/^(SortMergeJoin|BroadcastHashJoin|ShuffledHashJoin|BroadcastNestedLoopJoin)\s+(\[[^\]]*\]),\s*(\[[^\]]*\]),\s*(\w+)/);
139
+ if (m) {
140
+ result.joins.push({ joinType: m[1], leftKeys: parseColList(m[2]), rightKeys: parseColList(m[3]) });
141
+ if (/^Cross$/i.test(m[4])) pushCrossJoinWarning(result, 'Cross join');
142
+ return true;
143
+ }
144
+ return false;
145
+ }
146
+
147
+ function visitAgg(_name , detail , result ) {
148
+ const m = detail.match(/(?:Object)?HashAggregate\(keys=(\[[^\]]*\]),\s*functions=(\[[\s\S]*\])\)/);
149
+ if (!m) return false;
150
+ const funcs = m[2].replace(/^\[|\]$/g, '');
151
+ if (/\bpartial_/.test(funcs)) return true; // skip partial pass
152
+ const aggregations = splitTopLevel(funcs).map(f => f.replace(/#\w+/g, '').trim()).filter(Boolean).slice(0, 8);
153
+ const groupByKeys = parseColList(m[1]);
154
+ if (groupByKeys.length || aggregations.length) result.aggs.push({ groupByKeys, aggregations });
155
+ return true;
156
+ }
157
+
158
+ function visitExchange(detail , result ) {
159
+ const h = detail.match(/hashpartitioning\((.+?),\s*(\d+)\)/i);
160
+ const r = detail.match(/rangepartitioning\((.+?),\s*(\d+)\)/i);
161
+ if (h) {
162
+ result.exchanges.push({ partitioning: 'hash', keys: splitTopLevel(h[1]).map(s => cleanColRef(s.replace(/\s+(ASC|DESC)(\s+NULLS\s+\w+)?/i, ''))).filter(Boolean), numPartitions: parseInt(h[2], 10) });
163
+ } else if (r) {
164
+ result.exchanges.push({ partitioning: 'range', keys: splitTopLevel(r[1]).map(s => cleanColRef(s.replace(/\s+(ASC|DESC)(\s+NULLS\s+\w+)?/i, ''))).filter(Boolean), numPartitions: parseInt(r[2], 10) });
165
+ } else if (/RoundRobinPartitioning/i.test(detail)) {
166
+ const n = detail.match(/RoundRobinPartitioning\((\d+)\)/i);
167
+ result.exchanges.push({ partitioning: 'roundrobin', keys: [], numPartitions: n ? parseInt(n[1], 10) : 0 });
168
+ } else if (/SinglePartition/i.test(detail)) {
169
+ result.exchanges.push({ partitioning: 'single', keys: [], numPartitions: 1 });
170
+ }
171
+ }
172
+
173
+ export function summarizePlanTree(planTree ) {
174
+ const result = { scans: [], joins: [], aggs: [], exchanges: [], warnings: [] };
175
+ if (!planTree) return result;
176
+
177
+ walkPlanTree(planTree, (n) => {
178
+ const name = n.name ?? '';
179
+ const detail = n.detail ?? '';
180
+ if (/CartesianProduct/i.test(name) || /CartesianProduct/i.test(detail)) pushCrossJoinWarning(result, 'CartesianProduct');
181
+ else if (visitScan(name, detail, result)) { /* handled */ }
182
+ else if (/Join/i.test(name) && visitJoin(name, detail, result)) { /* handled */ }
183
+ else if (/Aggregate/i.test(name) && visitAgg(name, detail, result)) { /* handled */ }
184
+ else if (/^Exchange/i.test(name)) visitExchange(detail, result);
185
+ else if (/^Filter/i.test(name)) {
186
+ const condM = detail.match(/^Filter\s+([\s\S]+)/);
187
+ if (condM && condM[1].trim().length > 1000) pushLongFilterWarning(result, condM[1].trim().length);
188
+ }
189
+ }, { dedupe: true });
190
+ return result;
191
+ }
192
+
193
+ // Per-node structured detail: the same regex visitors as summarizePlanTree
194
+ // (visitScan/visitJoin/visitAgg/visitExchange, plus the crossJoin/
195
+ // longFilterCondition warning checks), scoped to a single node instead of
196
+ // folded into a tree-wide array. Used to attach the flat summary's full
197
+ // fields (every pushed filter, every join key, every partitioning key) to
198
+ // the exact operator they describe, and to attach a plan-text warning to
199
+ // the node that caused it instead of a disconnected list entry.
200
+ export function describePlanNode(
201
+ node ,
202
+ ) {
203
+ if (!node) return null;
204
+ const name = node.name ?? '';
205
+ const detail = node.detail ?? '';
206
+ const result = { scans: [], joins: [], aggs: [], exchanges: [], warnings: [] };
207
+
208
+ if (/CartesianProduct/i.test(name) || /CartesianProduct/i.test(detail)) {
209
+ pushCrossJoinWarning(result, 'CartesianProduct');
210
+ } else if (visitScan(name, detail, result)) {
211
+ // handled
212
+ } else if (/Join/i.test(name) && visitJoin(name, detail, result)) {
213
+ // handled (a Cross join type may also have pushed a crossJoin warning)
214
+ } else if (/Aggregate/i.test(name) && visitAgg(name, detail, result)) {
215
+ // handled (a partial-aggregation pass pushes nothing into result.aggs)
216
+ } else if (/^Exchange/i.test(name)) {
217
+ visitExchange(detail, result);
218
+ } else if (/^Filter/i.test(name)) {
219
+ const condM = detail.match(/^Filter\s+([\s\S]+)/);
220
+ if (condM && condM[1].trim().length > 1000) pushLongFilterWarning(result, condM[1].trim().length);
221
+ }
222
+
223
+ const kind = result.scans[0] ? 'scan'
224
+ : result.joins[0] ? 'join'
225
+ : result.aggs[0] ? 'agg'
226
+ : result.exchanges[0] ? 'exchange'
227
+ : null;
228
+ const fields = result.scans[0] ?? result.joins[0] ?? result.aggs[0] ?? result.exchanges[0] ?? null;
229
+ const warning = result.warnings[0] ?? null;
230
+
231
+ if (!kind && !warning) return null;
232
+ return { kind, warning, ...(fields ?? {}) };
233
+ }
@@ -0,0 +1,29 @@
1
+ // Canonical iterative walk over a resolved planTree (parser-worker.js
2
+ // resolvePlanTree). Every plan-*.js consumer and the sql-scope detectors in
3
+ // detectors.js need the same traversal: this is the single implementation
4
+ // they all share, so it must not import from detectors.js (or vice versa).
5
+ //
6
+ // Pre-order, left-to-right child order (children are reverse-pushed so the
7
+ // leftmost pops first): plan-summary.js's finding order depends on this.
8
+ // `visit(node, parent)` gets the parent so callers needing per-edge or
9
+ // per-node state (plan-dot.js's edges, plan-duration-attribution.js's segment
10
+ // index) can track it themselves via a Map keyed by node.
11
+ export function walkPlanTree (
12
+ root ,
13
+ visit ,
14
+ { dedupe = false } = {},
15
+ ) {
16
+ if (!root) return;
17
+ const seen = dedupe ? new Set () : null;
18
+ const stack = [{ node: root, parent: null }];
19
+ while (stack.length > 0) {
20
+ const { node, parent } = stack.pop() ;
21
+ if (seen) {
22
+ if (seen.has(node)) continue;
23
+ seen.add(node);
24
+ }
25
+ visit(node, parent);
26
+ const children = node.children ?? [];
27
+ for (let i = children.length - 1; i >= 0; i--) stack.push({ node: children[i], parent: node });
28
+ }
29
+ }
@@ -0,0 +1,157 @@
1
+ import { Readable } from 'node:stream';
2
+ import {
3
+ buildUpstreamUrl as buildNormalizedUpstreamUrl,
4
+ validateShsRequest,
5
+ } from './shs-request.js';
6
+
7
+ function sendSafeError(res, status, code) {
8
+ const body = JSON.stringify({ code });
9
+ res.writeHead(status, { 'content-type': 'application/json' });
10
+ res.end(body);
11
+ }
12
+
13
+ function isConnectionFailure(error) {
14
+ if (error?.name === 'AbortError' || error?.name === 'TimeoutError') return true;
15
+ const code = error?.code ?? error?.cause?.code;
16
+ if (['ECONNREFUSED', 'ECONNRESET', 'EHOSTUNREACH', 'ENETUNREACH', 'ENOTFOUND', 'ETIMEDOUT'].includes(code)) return true;
17
+ return /\b(?:ECONNREFUSED|ECONNRESET|EHOSTUNREACH|ENETUNREACH|ENOTFOUND|ETIMEDOUT)\b/.test(error?.message ?? '');
18
+ }
19
+
20
+ const DEFAULT_UPSTREAM_TIMEOUT_MS = 30_000;
21
+
22
+ function upstreamTimeoutMs() {
23
+ const v = Number(process.env.SPARKFORENSICS_SHS_TIMEOUT_MS);
24
+ return Number.isFinite(v) && v > 0 ? v : DEFAULT_UPSTREAM_TIMEOUT_MS;
25
+ }
26
+
27
+ // CSRF guard: verifies the caller, not the proxy target (`normalizeBaseUrl`'s
28
+ // private-network allowance is unrelated and deliberate). No Origin header
29
+ // means a same-origin navigation or a non-browser client, so it's allowed;
30
+ // an Origin present must match Host. Anything unparseable fails closed.
31
+ export function isSameOriginRequest(req) {
32
+ const origin = req.headers?.origin;
33
+ if (origin == null) return true;
34
+
35
+ const host = req.headers?.host;
36
+ if (!host) return false;
37
+
38
+ let originHost;
39
+ try {
40
+ originHost = new URL(origin).host;
41
+ } catch {
42
+ return false;
43
+ }
44
+ return originHost.toLowerCase() === host.toLowerCase();
45
+ }
46
+
47
+ export async function fetchShsEventLog(request, { fetchImpl = fetch, timeoutMs = upstreamTimeoutMs() } = {}) {
48
+ const upstreamUrl = buildNormalizedUpstreamUrl(request);
49
+ // Header-phase timeout only: the timer is cleared once headers arrive, so a
50
+ // slow-but-progressing body download is never cut off mid-stream (the body
51
+ // gets its own idle watchdog in handleShsProxy).
52
+ const controller = new AbortController();
53
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
54
+ let upstream;
55
+ try {
56
+ upstream = await fetchImpl(upstreamUrl, { redirect: 'error', credentials: 'omit', signal: controller.signal });
57
+ } catch (error) {
58
+ return { ok: false, code: isConnectionFailure(error) ? 'upstream-unreachable' : 'access-or-upstream-failure' };
59
+ } finally {
60
+ clearTimeout(timer);
61
+ }
62
+ if (!upstream?.ok) {
63
+ return { ok: false, code: upstream?.status === 404 ? 'application-not-found' : 'access-or-upstream-failure' };
64
+ }
65
+ if (!upstream.body) {
66
+ return { ok: false, code: 'access-or-upstream-failure' };
67
+ }
68
+ return { ok: true, upstream };
69
+ }
70
+
71
+ export async function handleShsProxy(req, res, { fetchImpl = fetch, timeoutMs = upstreamTimeoutMs() } = {}) {
72
+ if (!isSameOriginRequest(req)) {
73
+ sendSafeError(res, 403, 'access-or-upstream-failure');
74
+ return;
75
+ }
76
+
77
+ const query = new URL(req.url, 'http://localhost').searchParams;
78
+ const result = validateShsRequest({
79
+ baseUrl: query.get('baseUrl') ?? '',
80
+ appId: query.get('appId') ?? '',
81
+ attemptId: query.get('attemptId') ?? '',
82
+ });
83
+
84
+ if (!result.request) {
85
+ sendSafeError(res, 400, 'access-or-upstream-failure');
86
+ return;
87
+ }
88
+
89
+ const fetched = await fetchShsEventLog(result.request, { fetchImpl, timeoutMs });
90
+ if (!fetched.ok) {
91
+ sendSafeError(res, 502, fetched.code);
92
+ return;
93
+ }
94
+
95
+ const { upstream } = fetched;
96
+ let nodeStream;
97
+ try {
98
+ nodeStream = Readable.fromWeb(upstream.body);
99
+ } catch {
100
+ sendSafeError(res, 502, 'access-or-upstream-failure');
101
+ return;
102
+ }
103
+
104
+ const headers = {};
105
+ const contentLength = upstream.headers.get('content-length');
106
+ if (contentLength) headers['content-length'] = contentLength;
107
+ headers['content-type'] = 'application/zip';
108
+ res.writeHead(200, headers);
109
+
110
+ await new Promise((resolve) => {
111
+ // Idle watchdog: a stalled upstream would otherwise leave this promise
112
+ // pending forever with the 200 already sent. Resets on every chunk, so
113
+ // slow-but-progressing downloads of any size are unaffected.
114
+ let watchdog;
115
+ let settled = false;
116
+ const resetWatchdog = () => {
117
+ clearTimeout(watchdog);
118
+ watchdog = setTimeout(() => nodeStream.destroy(new Error('upstream idle timeout')), timeoutMs);
119
+ };
120
+ const finish = () => {
121
+ if (settled) return;
122
+ settled = true;
123
+ clearTimeout(watchdog);
124
+ resolve();
125
+ };
126
+ resetWatchdog();
127
+ nodeStream.on('data', resetWatchdog);
128
+ nodeStream.on('error', (err) => {
129
+ if (settled) return;
130
+ console.warn(`[shs-proxy] upstream stream failed mid-body: ${err.message}`);
131
+ // destroy(err), not destroy(): the 200 header is already out, so an
132
+ // abnormal termination is the only signal that the body is truncated.
133
+ res.destroy(err);
134
+ finish();
135
+ });
136
+ // Mirrors the nodeStream-error path in the other direction: a client
137
+ // disconnecting mid-transfer (res 'close') or a write failure (res
138
+ // 'error') must also settle this promise (resolve, same as the
139
+ // nodeStream-error path above) and stop the now-pointless upstream read,
140
+ // or it dangles until the idle watchdog eventually fires. `res` 'close'
141
+ // also fires after a normal 'finish', so the `settled` guard keeps that
142
+ // happy-path case a no-op.
143
+ res.on('error', (err) => {
144
+ if (settled) return;
145
+ console.warn(`[shs-proxy] client connection failed mid-body: ${err.message}`);
146
+ nodeStream.destroy(err);
147
+ finish();
148
+ });
149
+ res.on('close', () => {
150
+ if (settled) return;
151
+ nodeStream.destroy();
152
+ finish();
153
+ });
154
+ res.on('finish', finish);
155
+ nodeStream.pipe(res);
156
+ });
157
+ }