@milaboratories/pl-flight-recorder 0.2.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/README.md +119 -0
- package/dist/analyze.d.ts +133 -0
- package/dist/analyze.d.ts.map +1 -0
- package/dist/analyze.js +525 -0
- package/dist/analyze.js.map +1 -0
- package/dist/data_summary.d.ts +28 -0
- package/dist/data_summary.d.ts.map +1 -0
- package/dist/data_summary.js +73 -0
- package/dist/data_summary.js.map +1 -0
- package/dist/digest.d.ts +28 -0
- package/dist/digest.d.ts.map +1 -0
- package/dist/digest.js +55 -0
- package/dist/digest.js.map +1 -0
- package/dist/events.d.ts +89 -0
- package/dist/events.d.ts.map +1 -0
- package/dist/events.js +12 -0
- package/dist/events.js.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +13 -0
- package/dist/instrument.d.ts +82 -0
- package/dist/instrument.d.ts.map +1 -0
- package/dist/instrument.js +313 -0
- package/dist/instrument.js.map +1 -0
- package/dist/recorder.d.ts +82 -0
- package/dist/recorder.d.ts.map +1 -0
- package/dist/recorder.js +293 -0
- package/dist/recorder.js.map +1 -0
- package/dist/redact.d.ts +53 -0
- package/dist/redact.d.ts.map +1 -0
- package/dist/redact.js +145 -0
- package/dist/redact.js.map +1 -0
- package/dist/report.d.ts +6 -0
- package/dist/report.d.ts.map +1 -0
- package/dist/report.js +377 -0
- package/dist/report.js.map +1 -0
- package/dist/rules.d.ts +73 -0
- package/dist/rules.d.ts.map +1 -0
- package/dist/rules.js +245 -0
- package/dist/rules.js.map +1 -0
- package/dist/sampler.d.ts +22 -0
- package/dist/sampler.d.ts.map +1 -0
- package/dist/sampler.js +33 -0
- package/dist/sampler.js.map +1 -0
- package/dist/sampler_thread.d.ts +1 -0
- package/dist/sampler_thread.js +41 -0
- package/dist/sampler_thread.js.map +1 -0
- package/dist/session.d.ts +40 -0
- package/dist/session.d.ts.map +1 -0
- package/dist/session.js +50 -0
- package/dist/session.js.map +1 -0
- package/dist/supervisor.d.ts +58 -0
- package/dist/supervisor.d.ts.map +1 -0
- package/dist/supervisor.js +108 -0
- package/dist/supervisor.js.map +1 -0
- package/package.json +43 -0
- package/src/analyze.test.ts +539 -0
- package/src/analyze.ts +795 -0
- package/src/data_summary.ts +110 -0
- package/src/digest.ts +49 -0
- package/src/events.ts +102 -0
- package/src/index.ts +104 -0
- package/src/instrument.ts +442 -0
- package/src/recorder.ts +397 -0
- package/src/redact.test.ts +155 -0
- package/src/redact.ts +213 -0
- package/src/report.ts +512 -0
- package/src/rules.test.ts +182 -0
- package/src/rules.ts +383 -0
- package/src/sampler.ts +40 -0
- package/src/sampler_thread.ts +45 -0
- package/src/session.ts +69 -0
- package/src/supervisor.ts +150 -0
package/src/report.ts
ADDED
|
@@ -0,0 +1,512 @@
|
|
|
1
|
+
import { readSession } from "./recorder";
|
|
2
|
+
import { REDACTION } from "./digest";
|
|
3
|
+
import { axesUnder, axisKey, isJoinNode, joinChildren, joinShapes } from "./rules";
|
|
4
|
+
import { formatBytes, formatCount, type SessionAnalysis } from "./analyze";
|
|
5
|
+
import type { FlightRecord } from "./events";
|
|
6
|
+
|
|
7
|
+
/** Renders an analysis as the markdown report a developer reads. */
|
|
8
|
+
export function renderReport(analysis: SessionAnalysis): string {
|
|
9
|
+
const { records } = readSession(analysis.file);
|
|
10
|
+
const culprit = findCulpritJoin(records, analysis);
|
|
11
|
+
const offHeap = (analysis.memory.externalGrowth ?? 0) + (analysis.memory.arrayBuffersGrowth ?? 0);
|
|
12
|
+
|
|
13
|
+
return [
|
|
14
|
+
"# Platforma OOM flight report",
|
|
15
|
+
"",
|
|
16
|
+
`**Verdict — ${analysis.verdict.likelyCause ?? "no rule matched"}**`,
|
|
17
|
+
"",
|
|
18
|
+
analysis.verdict.summary,
|
|
19
|
+
"",
|
|
20
|
+
table("Where it stopped", [
|
|
21
|
+
row("Outcome", analysis.verdict.outcome),
|
|
22
|
+
row("Last unfinished operation", analysis.verdict.where),
|
|
23
|
+
row("Memory region that grew", analysis.verdict.memoryRegion ?? "not classified"),
|
|
24
|
+
row("Peak RSS", formatBytes(analysis.memory.peakRss)),
|
|
25
|
+
row("RSS at last sample", formatBytes(analysis.memory.rssAtDeath)),
|
|
26
|
+
row(
|
|
27
|
+
"JS heap at last record",
|
|
28
|
+
`${formatBytes(analysis.memory.heapUsedAtDeath)} of ${formatBytes(analysis.memory.heapLimit)}` +
|
|
29
|
+
(analysis.memory.heapPressure
|
|
30
|
+
? ` (${Math.round(analysis.memory.heapPressure * 100)}%)`
|
|
31
|
+
: ""),
|
|
32
|
+
),
|
|
33
|
+
row(
|
|
34
|
+
"Off-heap allocated (external + ArrayBuffers)",
|
|
35
|
+
`${formatBytes(offHeap)} — allocated size, which exceeds resident memory when pages are never written`,
|
|
36
|
+
),
|
|
37
|
+
row(
|
|
38
|
+
"Sampler series",
|
|
39
|
+
analysis.memory.samplerPresent
|
|
40
|
+
? `${analysis.memory.sampleCount} samples`
|
|
41
|
+
: "absent (in-thread records only)",
|
|
42
|
+
),
|
|
43
|
+
row("Worst recorded thread stall", `${Math.round(analysis.memory.worstStallMs)} ms`),
|
|
44
|
+
row("Log tail truncated by the kill", String(analysis.truncatedTail)),
|
|
45
|
+
row(
|
|
46
|
+
"Log rotations",
|
|
47
|
+
analysis.rotations === 0
|
|
48
|
+
? "none — the whole session is present"
|
|
49
|
+
: `${analysis.rotations} — operations older than the retained segments are absent`,
|
|
50
|
+
),
|
|
51
|
+
row(
|
|
52
|
+
"Supervisor crash marker",
|
|
53
|
+
analysis.crashMarker
|
|
54
|
+
? `${analysis.crashMarker.reason}${
|
|
55
|
+
analysis.crashMarker.errorCode ? ` (${analysis.crashMarker.errorCode})` : ""
|
|
56
|
+
}`
|
|
57
|
+
: "none — the parent process did not record the cause",
|
|
58
|
+
),
|
|
59
|
+
]),
|
|
60
|
+
"",
|
|
61
|
+
"## RSS over the session",
|
|
62
|
+
"",
|
|
63
|
+
"```",
|
|
64
|
+
sparkline(analysis.memory.rssSeries),
|
|
65
|
+
"```",
|
|
66
|
+
"",
|
|
67
|
+
findingsSection(analysis),
|
|
68
|
+
"",
|
|
69
|
+
attributionSection(analysis),
|
|
70
|
+
"",
|
|
71
|
+
culpritSection(culprit),
|
|
72
|
+
"",
|
|
73
|
+
rendersSection(analysis),
|
|
74
|
+
"",
|
|
75
|
+
timelineSection(analysis),
|
|
76
|
+
"",
|
|
77
|
+
nextStepsSection(analysis),
|
|
78
|
+
"",
|
|
79
|
+
table("Environment", [
|
|
80
|
+
row("Role", analysis.role),
|
|
81
|
+
row("Node", analysis.env?.node),
|
|
82
|
+
row("Platform", analysis.env?.platform),
|
|
83
|
+
row("Machine memory", formatBytes(analysis.env?.totalMemory)),
|
|
84
|
+
row("CPUs", analysis.env?.cpus),
|
|
85
|
+
row("V8 heap limit", formatBytes(analysis.env?.heapLimit)),
|
|
86
|
+
row("execArgv", (analysis.env?.execArgv ?? []).join(" ") || "(none)"),
|
|
87
|
+
row("App meta", JSON.stringify(analysis.meta ?? {})),
|
|
88
|
+
row("Session", analysis.sessionId),
|
|
89
|
+
row("Records", analysis.recordCount),
|
|
90
|
+
]),
|
|
91
|
+
"",
|
|
92
|
+
"## What this report contains",
|
|
93
|
+
"",
|
|
94
|
+
`Kept: ${REDACTION.kept.join(", ")}.`,
|
|
95
|
+
"",
|
|
96
|
+
`Never recorded: ${REDACTION.dropped.join(", ")}.`,
|
|
97
|
+
"",
|
|
98
|
+
].join("\n");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Internals
|
|
102
|
+
|
|
103
|
+
type Culprit = { seq: number; type: string; def: Record<string, unknown> };
|
|
104
|
+
|
|
105
|
+
function findingsSection(analysis: SessionAnalysis): string {
|
|
106
|
+
if (analysis.findings.length === 0) return "## Findings\n\nNo rule fired.";
|
|
107
|
+
const lines = ["## Findings", "", "| Severity | Rule | Detail |", "| --- | --- | --- |"];
|
|
108
|
+
for (const finding of analysis.findings) {
|
|
109
|
+
lines.push(`| ${finding.severity} | \`${finding.rule}\` | ${escapeCell(finding.detail)} |`);
|
|
110
|
+
}
|
|
111
|
+
return lines.join("\n");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function attributionSection(analysis: SessionAnalysis): string {
|
|
115
|
+
if (analysis.attribution.length === 0) {
|
|
116
|
+
return "## Memory attribution\n\nNo completed operation carried a memory delta.";
|
|
117
|
+
}
|
|
118
|
+
const lines = [
|
|
119
|
+
"## Memory attribution — RSS growth per completed operation",
|
|
120
|
+
"",
|
|
121
|
+
"| Op | seq | Duration | RSS delta | Heap delta |",
|
|
122
|
+
"| --- | --- | --- | --- | --- |",
|
|
123
|
+
];
|
|
124
|
+
for (const op of analysis.attribution) {
|
|
125
|
+
lines.push(
|
|
126
|
+
`| ${op.op} | ${op.seq} | ${op.ms ?? "?"} ms | ${formatBytes(op.rssDelta)} | ${formatBytes(
|
|
127
|
+
op.heapDelta ?? 0,
|
|
128
|
+
)} |`,
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
lines.push(
|
|
132
|
+
"",
|
|
133
|
+
"The operation that never completed does not appear here — it is in the verdict above.",
|
|
134
|
+
);
|
|
135
|
+
return lines.join("\n");
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function culpritSection(culprit: Culprit | undefined): string {
|
|
139
|
+
if (!culprit) {
|
|
140
|
+
return "## Definition in flight\n\nNo definition was recorded for the failing operation.";
|
|
141
|
+
}
|
|
142
|
+
const digest = culprit.def as
|
|
143
|
+
| { kind?: string; def?: unknown; redaction?: RedactionSummary }
|
|
144
|
+
| undefined;
|
|
145
|
+
const def = digest?.def;
|
|
146
|
+
const lines = [
|
|
147
|
+
`## Definition in flight (seq ${culprit.seq}, \`${culprit.type}\`, ${digest?.kind ?? "unknown shape"})`,
|
|
148
|
+
"",
|
|
149
|
+
"```",
|
|
150
|
+
renderDefTree(def, ""),
|
|
151
|
+
"```",
|
|
152
|
+
];
|
|
153
|
+
|
|
154
|
+
const elided = describeElisions(digest?.redaction);
|
|
155
|
+
if (elided) lines.push("", elided);
|
|
156
|
+
return lines.join("\n");
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
type RedactionSummary = {
|
|
160
|
+
bytes?: number;
|
|
161
|
+
hashedStrings?: number;
|
|
162
|
+
truncatedArrays?: number;
|
|
163
|
+
omittedItems?: number;
|
|
164
|
+
depthCapped?: number;
|
|
165
|
+
opaqueObjects?: number;
|
|
166
|
+
budgetExhausted?: boolean;
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
function describeElisions(redaction: RedactionSummary | undefined): string | undefined {
|
|
170
|
+
if (!redaction) return undefined;
|
|
171
|
+
const notes: string[] = [];
|
|
172
|
+
if (redaction.omittedItems) {
|
|
173
|
+
notes.push(
|
|
174
|
+
`${redaction.omittedItems} array item(s) omitted across ${redaction.truncatedArrays} array(s)`,
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
if (redaction.depthCapped) notes.push(`${redaction.depthCapped} subtree(s) cut at the depth cap`);
|
|
178
|
+
if (redaction.budgetExhausted)
|
|
179
|
+
notes.push("the node budget was exhausted, so the tail is missing");
|
|
180
|
+
const size = redaction.bytes !== undefined ? formatBytes(redaction.bytes) : undefined;
|
|
181
|
+
const all = [size, ...notes].filter(Boolean);
|
|
182
|
+
return all.length > 0 ? `Recorded shape: ${all.join("; ")}.` : undefined;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Renders the recorded definition as a tree.
|
|
187
|
+
*
|
|
188
|
+
* The walk is driven by the shape rather than by a known definition type: a node
|
|
189
|
+
* with a join discriminator gets the join line, a node carrying a column spec
|
|
190
|
+
* gets the column line, and anything else with a discriminator is printed as a
|
|
191
|
+
* pass-through step. The same code therefore renders both the original tree API
|
|
192
|
+
* and the V2 query API.
|
|
193
|
+
*/
|
|
194
|
+
function renderDefTree(node: unknown, indent: string, depth = 0): string {
|
|
195
|
+
if (depth > 24) return `${indent}…`;
|
|
196
|
+
if (node === null || node === undefined) return `${indent}(none)`;
|
|
197
|
+
if (Array.isArray(node)) {
|
|
198
|
+
return node.map((child) => renderDefTree(child, indent, depth + 1)).join("\n");
|
|
199
|
+
}
|
|
200
|
+
if (typeof node !== "object") return `${indent}${String(node)}`;
|
|
201
|
+
|
|
202
|
+
const record = node as Record<string, unknown>;
|
|
203
|
+
// Both APIs wrap a column as `{ type: "column", column: … }`, so the wrapper
|
|
204
|
+
// and the column it carries are printed as one line rather than two.
|
|
205
|
+
const payload = columnPayload(record);
|
|
206
|
+
if (payload) return `${indent}${columnLine(record, payload)}`;
|
|
207
|
+
|
|
208
|
+
if (isJoinNode(record)) {
|
|
209
|
+
const shape = joinShapes(record)[0];
|
|
210
|
+
const children = joinChildren(record);
|
|
211
|
+
const head =
|
|
212
|
+
`${indent}${shape?.join ?? "join"} children=${children.length}` +
|
|
213
|
+
` sharedAxes=${shape?.sharedAxes.length ?? 0}` +
|
|
214
|
+
(shape?.disjointPairs.length ? ` !! DISJOINT ${JSON.stringify(shape.disjointPairs)}` : "") +
|
|
215
|
+
` inputRowsMax=${formatCount(shape?.inputRowsMax)}` +
|
|
216
|
+
(shape?.rowsUpperBound ? ` rowsUpperBound=${formatCount(shape.rowsUpperBound)}` : "");
|
|
217
|
+
const axes = `${indent} axisUnion: ${(shape?.axisUnion ?? []).join(" ") || "(none)"}`;
|
|
218
|
+
return [
|
|
219
|
+
head,
|
|
220
|
+
axes,
|
|
221
|
+
...children.map((child) => renderDefTree(child, `${indent} `, depth + 1)),
|
|
222
|
+
].join("\n");
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const discriminator = typeof record.type === "string" ? record.type : undefined;
|
|
226
|
+
const interesting = Object.entries(record).filter(([, value]) => isStructural(value));
|
|
227
|
+
if (discriminator) {
|
|
228
|
+
const detail = [
|
|
229
|
+
filtersNote(record),
|
|
230
|
+
typeof record.$omitted === "number" ? `omitted=${record.$omitted}` : undefined,
|
|
231
|
+
]
|
|
232
|
+
.filter(Boolean)
|
|
233
|
+
.join(" ");
|
|
234
|
+
const head = `${indent}${discriminator}${detail ? ` ${detail}` : ""}`;
|
|
235
|
+
return [
|
|
236
|
+
head,
|
|
237
|
+
...interesting.map(([, value]) => renderDefTree(value, `${indent} `, depth + 1)),
|
|
238
|
+
].join("\n");
|
|
239
|
+
}
|
|
240
|
+
// A transparent wrapper (the V2 `{ entry }` shape, or a plain container):
|
|
241
|
+
// print nothing for it and keep the indentation of its parent.
|
|
242
|
+
return interesting.length > 0
|
|
243
|
+
? interesting.map(([, value]) => renderDefTree(value, indent, depth + 1)).join("\n")
|
|
244
|
+
: `${indent}${describeLeafObject(record)}`;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function filtersNote(record: Record<string, unknown>): string | undefined {
|
|
248
|
+
const parts: string[] = [];
|
|
249
|
+
for (const key of ["filters", "partitionFilters", "sorting"]) {
|
|
250
|
+
const value = record[key];
|
|
251
|
+
if (Array.isArray(value) && value.length > 0) parts.push(`${key}=${value.length}`);
|
|
252
|
+
}
|
|
253
|
+
const predicate = record.predicate as { operator?: unknown } | undefined;
|
|
254
|
+
if (typeof predicate?.operator === "string") parts.push(`op=${predicate.operator}`);
|
|
255
|
+
return parts.length > 0 ? parts.join(" ") : undefined;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function columnLine(outer: Record<string, unknown>, payload: Record<string, unknown>): string {
|
|
259
|
+
const spec = (payload.spec ?? {}) as {
|
|
260
|
+
name?: unknown;
|
|
261
|
+
valueType?: unknown;
|
|
262
|
+
axesSpec?: unknown[];
|
|
263
|
+
};
|
|
264
|
+
const data = (payload.data ?? payload.dataInfo ?? {}) as {
|
|
265
|
+
kind?: string;
|
|
266
|
+
rows?: number;
|
|
267
|
+
bytes?: number;
|
|
268
|
+
parts?: number;
|
|
269
|
+
entries?: number;
|
|
270
|
+
};
|
|
271
|
+
const axes = axesUnder(payload).map(axisKey).join(" , ");
|
|
272
|
+
const rows = data.rows ?? data.entries;
|
|
273
|
+
return (
|
|
274
|
+
`${typeof outer.type === "string" ? outer.type : "column"} ` +
|
|
275
|
+
`${asText(spec.name)} : ${asText(spec.valueType)}` +
|
|
276
|
+
` axes=[${axes}]` +
|
|
277
|
+
` data=${data.kind ?? "?"}` +
|
|
278
|
+
(data.parts !== undefined ? ` parts=${data.parts}` : "") +
|
|
279
|
+
(rows !== undefined ? ` rows=${formatCount(rows)}` : " rows=unknown") +
|
|
280
|
+
(data.bytes !== undefined ? ` bytes=${formatBytes(data.bytes)}` : "")
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** The record carrying a column spec: the node itself, or the column it wraps. */
|
|
285
|
+
function columnPayload(record: Record<string, unknown>): Record<string, unknown> | undefined {
|
|
286
|
+
if (carriesSpec(record)) return record;
|
|
287
|
+
const inner = record.column;
|
|
288
|
+
return carriesSpec(inner) ? (inner as Record<string, unknown>) : undefined;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function carriesSpec(value: unknown): boolean {
|
|
292
|
+
if (typeof value !== "object" || value === null) return false;
|
|
293
|
+
const spec = (value as { spec?: { axesSpec?: unknown } }).spec;
|
|
294
|
+
return Array.isArray(spec?.axesSpec);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function isStructural(value: unknown): boolean {
|
|
298
|
+
return typeof value === "object" && value !== null;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function describeLeafObject(record: Record<string, unknown>): string {
|
|
302
|
+
if (typeof record.$omitted === "number") return `… ${record.$omitted} more omitted`;
|
|
303
|
+
if (record.$depth !== undefined) return "… cut at the depth cap";
|
|
304
|
+
if (record.$budget !== undefined) return "… node budget exhausted";
|
|
305
|
+
if (typeof record.$opaque === "string") return `(${record.$opaque})`;
|
|
306
|
+
const keys = Object.keys(record);
|
|
307
|
+
return keys.length > 0 ? `{ ${keys.join(", ")} }` : "{}";
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function asText(value: unknown): string {
|
|
311
|
+
return typeof value === "string" ? value : "?";
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function rendersSection(analysis: SessionAnalysis): string {
|
|
315
|
+
if (analysis.renders.length === 0) return "## Model renders\n\nNone recorded.";
|
|
316
|
+
const lines = [
|
|
317
|
+
"## Model renders",
|
|
318
|
+
"",
|
|
319
|
+
"| Block | seq | Finished | ms | Sandbox bytes out |",
|
|
320
|
+
"| --- | --- | --- | --- | --- |",
|
|
321
|
+
];
|
|
322
|
+
for (const render of analysis.renders.slice(-12)) {
|
|
323
|
+
const name = render.blockId ?? render.block ?? render.key ?? "?";
|
|
324
|
+
const finished = render.end ? (render.failed ? "error" : "yes") : "**NO**";
|
|
325
|
+
const serOut =
|
|
326
|
+
render.stats?.serOutBytes !== undefined ? formatBytes(render.stats.serOutBytes) : "-";
|
|
327
|
+
lines.push(`| ${name} | ${render.seq} | ${finished} | ${render.ms ?? "-"} | ${serOut} |`);
|
|
328
|
+
}
|
|
329
|
+
return lines.join("\n");
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function timelineSection(analysis: SessionAnalysis): string {
|
|
333
|
+
const lines = ["## Last records before the log ends", "", "```"];
|
|
334
|
+
for (const record of analysis.timeline) {
|
|
335
|
+
const type = record.type as string;
|
|
336
|
+
if (type === "mem-sampler" || type === "mem-self") continue;
|
|
337
|
+
const rss = record.rss as number | undefined;
|
|
338
|
+
const time = new Date(record.wall as number).toISOString().slice(11, 23);
|
|
339
|
+
lines.push(
|
|
340
|
+
`${String(record.seq).padStart(5)} ${time} ` +
|
|
341
|
+
`${(rss !== undefined ? formatBytes(rss) : "").padStart(9)} ${type}` +
|
|
342
|
+
extraFields(record),
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
lines.push("```");
|
|
346
|
+
return lines.join("\n");
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const TIMELINE_FIELDS = [
|
|
350
|
+
"blockId",
|
|
351
|
+
"handle",
|
|
352
|
+
"rows",
|
|
353
|
+
"columns",
|
|
354
|
+
"amplification",
|
|
355
|
+
"returnedBytes",
|
|
356
|
+
"unbounded",
|
|
357
|
+
"tableRows",
|
|
358
|
+
"columnCount",
|
|
359
|
+
"ms",
|
|
360
|
+
"error",
|
|
361
|
+
];
|
|
362
|
+
|
|
363
|
+
function extraFields(record: Record<string, unknown>): string {
|
|
364
|
+
const parts: string[] = [];
|
|
365
|
+
for (const key of TIMELINE_FIELDS) {
|
|
366
|
+
const value = record[key];
|
|
367
|
+
if (value === undefined) continue;
|
|
368
|
+
parts.push(`${key}=${key === "returnedBytes" ? formatBytes(value as number) : value}`);
|
|
369
|
+
}
|
|
370
|
+
const range = record.range as { offset: number; length: number } | null | undefined;
|
|
371
|
+
if (range) parts.push(`range=${range.offset}+${range.length}`);
|
|
372
|
+
if (record.defSummary) parts.push(`def=${JSON.stringify(record.defSummary)}`);
|
|
373
|
+
return parts.length > 0 ? ` ${parts.join(" ")}` : "";
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const NEXT_STEPS: { rule: string; step: string }[] = [
|
|
377
|
+
{
|
|
378
|
+
rule: "cross-join",
|
|
379
|
+
step: "The join tree has siblings with no axis in common. Find where the model assembles that join and check the axis specs of the columns it pulls from the result pool — output size is the product of the inputs.",
|
|
380
|
+
},
|
|
381
|
+
{
|
|
382
|
+
rule: "axis-domain-mismatch",
|
|
383
|
+
step: "The same axis name and type appears with different domains inside one join, so the join key does not match. Compare the domains listed in the findings against what the producing block writes.",
|
|
384
|
+
},
|
|
385
|
+
{
|
|
386
|
+
rule: "unbounded-getData",
|
|
387
|
+
step: "A getData call has no row range. Page it, or drive it from the table viewport.",
|
|
388
|
+
},
|
|
389
|
+
{
|
|
390
|
+
rule: "join-amplification",
|
|
391
|
+
step: "Output rows far exceed the largest input. Log the axis union at each join node and find the level where the count explodes.",
|
|
392
|
+
},
|
|
393
|
+
{
|
|
394
|
+
rule: "huge-inline-column",
|
|
395
|
+
step: "The model built a large inline column inside the sandbox. Move that work into the workflow so it never crosses the QuickJS boundary.",
|
|
396
|
+
},
|
|
397
|
+
{
|
|
398
|
+
rule: "js-heap-exhaustion-confirmed",
|
|
399
|
+
step: "The supervisor confirmed a JS heap limit breach in the middle-layer thread, so the growth is in JavaScript objects, not the native engine. Reproduce with --heapsnapshot-near-heap-limit=1 on that worker to get the retaining set.",
|
|
400
|
+
},
|
|
401
|
+
{
|
|
402
|
+
rule: "js-heap-exhaustion",
|
|
403
|
+
step: "Growth is inside the JS heap. A heap snapshot from the next reproduction would name the retaining objects.",
|
|
404
|
+
},
|
|
405
|
+
{
|
|
406
|
+
rule: "off-heap-buffer-growth",
|
|
407
|
+
step: "Growth is outside the JS heap, so raising --max-old-space-size will not help. Ask for a pframes engine heap profile (pprofDump) alongside this report.",
|
|
408
|
+
},
|
|
409
|
+
{
|
|
410
|
+
rule: "native-allocation-growth",
|
|
411
|
+
step: "Growth is outside the JS heap, so raising --max-old-space-size will not help. Ask for a pframes engine heap profile (pprofDump) alongside this report.",
|
|
412
|
+
},
|
|
413
|
+
{
|
|
414
|
+
rule: "unattributed-crash-marker",
|
|
415
|
+
step: "A crash marker could not be attributed because more than one session in the directory stopped writing around the same time. Spawn the middle-layer worker with an assigned flight session id, which removes the ambiguity entirely.",
|
|
416
|
+
},
|
|
417
|
+
{
|
|
418
|
+
rule: "heap-reading-stale",
|
|
419
|
+
step: "The heap was not sampled near the crash because the thread was blocked. Treat the heap numbers in this report as a floor, not a measurement.",
|
|
420
|
+
},
|
|
421
|
+
];
|
|
422
|
+
|
|
423
|
+
function nextStepsSection(analysis: SessionAnalysis): string {
|
|
424
|
+
const rules = new Set(analysis.findings.map((finding) => finding.rule));
|
|
425
|
+
const steps = NEXT_STEPS.filter((entry) => rules.has(entry.rule)).map((entry) => entry.step);
|
|
426
|
+
if (analysis.rotations > 1) {
|
|
427
|
+
steps.push(
|
|
428
|
+
"This session rotated more than once, so only the last two segments are on disk and the earliest operations are gone. The verdict above rests on the tail, which is intact; treat the operation list as partial.",
|
|
429
|
+
);
|
|
430
|
+
}
|
|
431
|
+
if (!analysis.memory.samplerPresent) {
|
|
432
|
+
steps.push(
|
|
433
|
+
"No sampler series in this session: the RSS curve came from in-thread records only and may have gaps where the thread was blocked.",
|
|
434
|
+
);
|
|
435
|
+
}
|
|
436
|
+
if (steps.length === 0) {
|
|
437
|
+
steps.push("No rule fired. Send the raw flight log so the thresholds can be revisited.");
|
|
438
|
+
}
|
|
439
|
+
return ["## Next steps", "", ...steps.map((step, index) => `${index + 1}. ${step}`)].join("\n");
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function findCulpritJoin(records: FlightRecord[], analysis: SessionAnalysis): Culprit | undefined {
|
|
443
|
+
const bySeq = new Map(records.map((record) => [record.seq, record]));
|
|
444
|
+
const inFlight = analysis.inFlightAtDeath;
|
|
445
|
+
|
|
446
|
+
// An in-flight data call points back at the join that produced its handle.
|
|
447
|
+
if (inFlight) {
|
|
448
|
+
const beginRecord = bySeq.get(inFlight.seq);
|
|
449
|
+
if (beginRecord?.def) {
|
|
450
|
+
return {
|
|
451
|
+
seq: inFlight.seq,
|
|
452
|
+
type: inFlight.op,
|
|
453
|
+
def: beginRecord.def as Record<string, unknown>,
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
const joinSeq = beginRecord?.joinSeq as number | undefined;
|
|
457
|
+
const join = joinSeq === undefined ? undefined : bySeq.get(joinSeq);
|
|
458
|
+
if (join?.def) {
|
|
459
|
+
return { seq: join.seq, type: opName(join.type), def: join.def as Record<string, unknown> };
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
// Otherwise the most recent join carrying a structural finding.
|
|
463
|
+
const flagged = records.findLast(
|
|
464
|
+
(record) => record.def && (record.findings as unknown[] | undefined)?.length,
|
|
465
|
+
);
|
|
466
|
+
const fallback = flagged ?? records.findLast((record) => record.def);
|
|
467
|
+
return fallback
|
|
468
|
+
? {
|
|
469
|
+
seq: fallback.seq,
|
|
470
|
+
type: opName(fallback.type),
|
|
471
|
+
def: fallback.def as Record<string, unknown>,
|
|
472
|
+
}
|
|
473
|
+
: undefined;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function opName(recordType: string): string {
|
|
477
|
+
return recordType.replace(/-(begin|end|error)$/, "");
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function sparkline(series: { wall: number; rss?: number }[], width = 72): string {
|
|
481
|
+
if (series.length === 0) return "(no samples)";
|
|
482
|
+
const values = series.map((sample) => sample.rss ?? 0);
|
|
483
|
+
const max = Math.max(...values);
|
|
484
|
+
const min = Math.min(...values);
|
|
485
|
+
const chars = "▁▂▃▄▅▆▇█";
|
|
486
|
+
const step = Math.max(1, Math.ceil(values.length / width));
|
|
487
|
+
let line = "";
|
|
488
|
+
for (let i = 0; i < values.length; i += step) {
|
|
489
|
+
const bucket = values.slice(i, i + step);
|
|
490
|
+
const peak = Math.max(...bucket);
|
|
491
|
+
const index = max === min ? 0 : Math.round(((peak - min) / (max - min)) * (chars.length - 1));
|
|
492
|
+
line += chars[index];
|
|
493
|
+
}
|
|
494
|
+
const spanMs = (series[series.length - 1].wall ?? 0) - (series[0].wall ?? 0);
|
|
495
|
+
return `${line}\n${formatBytes(min)} → ${formatBytes(max)} over ${(spanMs / 1000).toFixed(1)}s (${
|
|
496
|
+
values.length
|
|
497
|
+
} samples)`;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function table(title: string, rows: string[]): string {
|
|
501
|
+
return [`## ${title}`, "", "| | |", "| --- | --- |", ...rows].join("\n");
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function row(key: string, value: unknown): string {
|
|
505
|
+
return `| ${key} | ${value ?? "unknown"} |`;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function escapeCell(value: string): string {
|
|
509
|
+
return String(value ?? "")
|
|
510
|
+
.replace(/\|/g, "\\|")
|
|
511
|
+
.replace(/\n/g, " ");
|
|
512
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import { digestDef } from "./digest";
|
|
3
|
+
import { inputRowsMax, joinShapes, structuralFindings } from "./rules";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The rules are exercised through the real path: a definition is redacted first,
|
|
7
|
+
* then read. Anything the redaction drops is therefore also missing here, which
|
|
8
|
+
* is the point — a rule that only works on the raw definition would never fire
|
|
9
|
+
* in production.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
describe("structural findings, tree API", () => {
|
|
13
|
+
test("identical axes produce no finding", () => {
|
|
14
|
+
const def = ptableDef(
|
|
15
|
+
inner([
|
|
16
|
+
column("a", [axis("pl7.app/sampleId")], 100),
|
|
17
|
+
column("b", [axis("pl7.app/sampleId")], 200),
|
|
18
|
+
]),
|
|
19
|
+
);
|
|
20
|
+
expect(findings(def, "PTableDef")).toEqual([]);
|
|
21
|
+
const shape = shapes(def, "PTableDef")[0];
|
|
22
|
+
expect(shape.sharedAxes).toEqual(["String|pl7.app/sampleId|"]);
|
|
23
|
+
expect(shape.inputRowsMax).toBe(200);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("siblings with no shared axis are a cross join with a row bound", () => {
|
|
27
|
+
const def = ptableDef(
|
|
28
|
+
inner([column("a", [axis("s")], 384), column("b", [axis("c")], 2_400_000)]),
|
|
29
|
+
);
|
|
30
|
+
const cross = findings(def, "PTableDef").find((f) => f.rule === "cross-join");
|
|
31
|
+
expect(cross?.severity).toBe("critical");
|
|
32
|
+
expect(shapes(def, "PTableDef")[0].rowsUpperBound).toBe(384 * 2_400_000);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("same axis under different domains is named as a domain mismatch", () => {
|
|
36
|
+
const def = ptableDef(
|
|
37
|
+
inner([
|
|
38
|
+
column("a", [axis("pl7.app/vdj/clonotypeKey", { "pl7.app/vdj/chain": "IGH" })], 100),
|
|
39
|
+
column("b", [axis("pl7.app/vdj/clonotypeKey", { "pl7.app/vdj/chain": "IGK" })], 200),
|
|
40
|
+
]),
|
|
41
|
+
);
|
|
42
|
+
const mismatch = findings(def, "PTableDef").find((f) => f.rule === "axis-domain-mismatch");
|
|
43
|
+
expect(mismatch?.severity).toBe("high");
|
|
44
|
+
expect(mismatch?.domains).toHaveLength(2);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("fan-out is reported for an inner join that still has a working key", () => {
|
|
48
|
+
const def = ptableDef(
|
|
49
|
+
inner([column("a", [axis("s")], 100), column("b", [axis("s"), axis("c")], 5000)]),
|
|
50
|
+
);
|
|
51
|
+
expect(findings(def, "PTableDef").map((f) => f.rule)).toEqual(["partial-key-fan-out"]);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("fan-out is not restated on a cartesian node", () => {
|
|
55
|
+
const def = ptableDef(inner([column("a", [axis("s")], 10), column("b", [axis("c")], 10)]));
|
|
56
|
+
expect(findings(def, "PTableDef").some((f) => f.rule === "partial-key-fan-out")).toBe(false);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("an outer join's narrower secondary is not a finding", () => {
|
|
60
|
+
const def = ptableDef({
|
|
61
|
+
type: "outer",
|
|
62
|
+
primary: column("a", [axis("s"), axis("c")], 1000),
|
|
63
|
+
secondary: [column("b", [axis("c")], 50)],
|
|
64
|
+
});
|
|
65
|
+
expect(findings(def, "PTableDef")).toEqual([]);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("a nested join is reached and reported by its path", () => {
|
|
69
|
+
const def = ptableDef(
|
|
70
|
+
inner([
|
|
71
|
+
column("a", [axis("s")], 10),
|
|
72
|
+
inner([column("b", [axis("s")], 20), column("c", [axis("z")], 30)]),
|
|
73
|
+
]),
|
|
74
|
+
);
|
|
75
|
+
const cross = findings(def, "PTableDef").find((f) => f.rule === "cross-join");
|
|
76
|
+
expect(cross?.path).toBe("root/inner[1]");
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe("structural findings, V2 query API", () => {
|
|
81
|
+
test("the same cross join is found in a V2 query", () => {
|
|
82
|
+
const def = {
|
|
83
|
+
query: v2Join("innerJoin", [
|
|
84
|
+
column("a", [axis("s")], 384),
|
|
85
|
+
column("b", [axis("c")], 2_400_000),
|
|
86
|
+
]),
|
|
87
|
+
};
|
|
88
|
+
const found = findings(def, "PTableDefV2");
|
|
89
|
+
expect(found.map((f) => f.rule)).toContain("cross-join");
|
|
90
|
+
expect(shapes(def, "PTableDefV2")[0].rowsUpperBound).toBe(384 * 2_400_000);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("a V2 domain mismatch is found through the entry wrapper", () => {
|
|
94
|
+
const def = {
|
|
95
|
+
query: v2Join("innerJoin", [
|
|
96
|
+
column("a", [axis("pl7.app/vdj/clonotypeKey", { chain: "IGH" })], 100),
|
|
97
|
+
column("b", [axis("pl7.app/vdj/clonotypeKey", { chain: "IGK" })], 200),
|
|
98
|
+
]),
|
|
99
|
+
};
|
|
100
|
+
expect(findings(def, "PTableDefV2").map((f) => f.rule)).toContain("axis-domain-mismatch");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("a healthy V2 outer join yields nothing", () => {
|
|
104
|
+
const def = {
|
|
105
|
+
query: {
|
|
106
|
+
type: "outerJoin",
|
|
107
|
+
primary: { entry: column("a", [axis("s"), axis("c")], 1000) },
|
|
108
|
+
secondary: [{ entry: column("b", [axis("c")], 50) }],
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
expect(findings(def, "PTableDefV2")).toEqual([]);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe("declared input rows", () => {
|
|
116
|
+
test("row counts come from parquet chunk stats without reading blobs", () => {
|
|
117
|
+
const def = ptableDef(inner([column("a", [axis("s")], 1000), column("b", [axis("s")], 3000)]));
|
|
118
|
+
expect(inputRowsMax(digestDef("PTableDef", def).def)).toBe(3000);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("rows stay unknown when the workflow wrote no chunk stats", () => {
|
|
122
|
+
const noStats = {
|
|
123
|
+
type: "column",
|
|
124
|
+
column: {
|
|
125
|
+
id: "x",
|
|
126
|
+
spec: { kind: "PColumn", name: "x", valueType: "Int", axesSpec: [axis("s")] },
|
|
127
|
+
data: {
|
|
128
|
+
type: "ParquetPartitioned",
|
|
129
|
+
partitionKeyLength: 1,
|
|
130
|
+
parts: { "[0]": { data: "b" } },
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
const def = ptableDef(inner([noStats, column("b", [axis("s")], 10)]));
|
|
135
|
+
const shape = shapes(def, "PTableDef")[0];
|
|
136
|
+
expect(shape.rowsUpperBound).toBeUndefined();
|
|
137
|
+
expect(shape.inputRowsMax).toBe(10);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// Internals
|
|
142
|
+
|
|
143
|
+
function findings(def: unknown, kind: "PTableDef" | "PTableDefV2") {
|
|
144
|
+
return structuralFindings(digestDef(kind, def).def);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function shapes(def: unknown, kind: "PTableDef" | "PTableDefV2") {
|
|
148
|
+
return joinShapes(digestDef(kind, def).def);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function ptableDef(src: unknown): unknown {
|
|
152
|
+
return { src, partitionFilters: [], filters: [], sorting: [] };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function inner(entries: unknown[]): unknown {
|
|
156
|
+
return { type: "inner", entries };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function v2Join(type: string, columns: unknown[]): unknown {
|
|
160
|
+
return { type, entries: columns.map((entry) => ({ entry })) };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function axis(name: string, domain?: Record<string, string>): unknown {
|
|
164
|
+
return { type: "String", name, ...(domain ? { domain } : {}) };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function column(name: string, axes: unknown[], rows: number): unknown {
|
|
168
|
+
return {
|
|
169
|
+
type: "column",
|
|
170
|
+
column: {
|
|
171
|
+
id: `id-${name}`,
|
|
172
|
+
spec: { kind: "PColumn", name, valueType: "Int", axesSpec: axes },
|
|
173
|
+
data: {
|
|
174
|
+
type: "ParquetPartitioned",
|
|
175
|
+
partitionKeyLength: 1,
|
|
176
|
+
parts: {
|
|
177
|
+
"[0]": { data: "blob", stats: { numberOfRows: rows, size: { axes: [8], column: 8 } } },
|
|
178
|
+
},
|
|
179
|
+
},
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
}
|