@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.
Files changed (72) hide show
  1. package/README.md +119 -0
  2. package/dist/analyze.d.ts +133 -0
  3. package/dist/analyze.d.ts.map +1 -0
  4. package/dist/analyze.js +525 -0
  5. package/dist/analyze.js.map +1 -0
  6. package/dist/data_summary.d.ts +28 -0
  7. package/dist/data_summary.d.ts.map +1 -0
  8. package/dist/data_summary.js +73 -0
  9. package/dist/data_summary.js.map +1 -0
  10. package/dist/digest.d.ts +28 -0
  11. package/dist/digest.d.ts.map +1 -0
  12. package/dist/digest.js +55 -0
  13. package/dist/digest.js.map +1 -0
  14. package/dist/events.d.ts +89 -0
  15. package/dist/events.d.ts.map +1 -0
  16. package/dist/events.js +12 -0
  17. package/dist/events.js.map +1 -0
  18. package/dist/index.d.ts +13 -0
  19. package/dist/index.js +13 -0
  20. package/dist/instrument.d.ts +82 -0
  21. package/dist/instrument.d.ts.map +1 -0
  22. package/dist/instrument.js +313 -0
  23. package/dist/instrument.js.map +1 -0
  24. package/dist/recorder.d.ts +82 -0
  25. package/dist/recorder.d.ts.map +1 -0
  26. package/dist/recorder.js +293 -0
  27. package/dist/recorder.js.map +1 -0
  28. package/dist/redact.d.ts +53 -0
  29. package/dist/redact.d.ts.map +1 -0
  30. package/dist/redact.js +145 -0
  31. package/dist/redact.js.map +1 -0
  32. package/dist/report.d.ts +6 -0
  33. package/dist/report.d.ts.map +1 -0
  34. package/dist/report.js +377 -0
  35. package/dist/report.js.map +1 -0
  36. package/dist/rules.d.ts +73 -0
  37. package/dist/rules.d.ts.map +1 -0
  38. package/dist/rules.js +245 -0
  39. package/dist/rules.js.map +1 -0
  40. package/dist/sampler.d.ts +22 -0
  41. package/dist/sampler.d.ts.map +1 -0
  42. package/dist/sampler.js +33 -0
  43. package/dist/sampler.js.map +1 -0
  44. package/dist/sampler_thread.d.ts +1 -0
  45. package/dist/sampler_thread.js +41 -0
  46. package/dist/sampler_thread.js.map +1 -0
  47. package/dist/session.d.ts +40 -0
  48. package/dist/session.d.ts.map +1 -0
  49. package/dist/session.js +50 -0
  50. package/dist/session.js.map +1 -0
  51. package/dist/supervisor.d.ts +58 -0
  52. package/dist/supervisor.d.ts.map +1 -0
  53. package/dist/supervisor.js +108 -0
  54. package/dist/supervisor.js.map +1 -0
  55. package/package.json +43 -0
  56. package/src/analyze.test.ts +539 -0
  57. package/src/analyze.ts +795 -0
  58. package/src/data_summary.ts +110 -0
  59. package/src/digest.ts +49 -0
  60. package/src/events.ts +102 -0
  61. package/src/index.ts +104 -0
  62. package/src/instrument.ts +442 -0
  63. package/src/recorder.ts +397 -0
  64. package/src/redact.test.ts +155 -0
  65. package/src/redact.ts +213 -0
  66. package/src/report.ts +512 -0
  67. package/src/rules.test.ts +182 -0
  68. package/src/rules.ts +383 -0
  69. package/src/sampler.ts +40 -0
  70. package/src/sampler_thread.ts +45 -0
  71. package/src/session.ts +69 -0
  72. package/src/supervisor.ts +150 -0
@@ -0,0 +1,539 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { afterEach, beforeEach, describe, expect, test } from "vitest";
5
+ import { openRecorder, listSessions, type Recorder } from "./recorder";
6
+ import { readCrashMarkers, writeCrashMarker } from "./supervisor";
7
+ import { analyzeLatest, analyzeSession } from "./analyze";
8
+ import { renderReport } from "./report";
9
+ import {
10
+ createHandleRegistry,
11
+ recordModelRender,
12
+ wrapDataDriver,
13
+ wrapModelDriver,
14
+ } from "./instrument";
15
+
16
+ let dir: string;
17
+
18
+ beforeEach(() => {
19
+ dir = fs.mkdtempSync(path.join(os.tmpdir(), "flight-test-"));
20
+ });
21
+
22
+ afterEach(() => {
23
+ fs.rmSync(dir, { recursive: true, force: true });
24
+ });
25
+
26
+ describe("crash detection", () => {
27
+ test("a session with no terminating record is reported as crashed", () => {
28
+ const recorder = openRecorder({ dir, meta: { appVersion: "test" } });
29
+ recorder.event("getShape-begin", { handle: "t1" });
30
+ // Deliberately not closed: this is what a killed process leaves behind.
31
+
32
+ const sessions = listSessions(dir);
33
+ expect(sessions).toHaveLength(1);
34
+ expect(sessions[0].crashed).toBe(true);
35
+
36
+ const analysis = analyzeSession(sessions[0].file, dir);
37
+ expect(analysis.crashed).toBe(true);
38
+ expect(analysis.inFlightAtDeath?.op).toBe("getShape");
39
+ });
40
+
41
+ test("a closed session is reported as clean and has nothing in flight", () => {
42
+ const recorder = openRecorder({ dir });
43
+ recorder.event("getShape-begin", { handle: "t1" });
44
+ recorder.event("getShape-end", { begin: 2, rows: 10, columns: 2 });
45
+ recorder.close();
46
+
47
+ const analysis = analyzeLatest(dir, { preferCrashed: false })!;
48
+ expect(analysis.crashed).toBe(false);
49
+ expect(analysis.inFlight).toEqual([]);
50
+ expect(analysis.verdict.where).toBe("no operation was in flight");
51
+ });
52
+
53
+ test("the innermost unfinished operation is the smoking gun", () => {
54
+ const recorder = openRecorder({ dir });
55
+ recorder.event("render-begin", { blockId: "block-7" });
56
+ recorder.event("getShape-begin", { handle: "t1" });
57
+ recorder.event("getShape-end", { begin: 3 });
58
+ recorder.event("getData-begin", { handle: "t1", unbounded: true, tableRows: 5_000_000 });
59
+
60
+ const analysis = analyzeLatest(dir)!;
61
+ expect(analysis.inFlight.map((op) => op.op)).toEqual(["render", "getData"]);
62
+ expect(analysis.inFlightAtDeath?.op).toBe("getData");
63
+ expect(analysis.findings.map((f) => f.rule)).toContain("unbounded-getData");
64
+ });
65
+ });
66
+
67
+ describe("cause classification", () => {
68
+ test("a supervisor marker turns an inferred heap death into a confirmed one", () => {
69
+ const recorder = openRecorder({ dir });
70
+ recorder.event("getData-begin", { handle: "t1" });
71
+ writeCrashMarker(dir, {
72
+ error: Object.assign(new Error("Worker terminated due to reaching memory limit"), {
73
+ code: "ERR_WORKER_OUT_OF_MEMORY",
74
+ }),
75
+ });
76
+
77
+ const analysis = analyzeSession(recorder.file, dir);
78
+ expect(analysis.crashMarker?.reason).toBe("js-heap-out-of-memory");
79
+ expect(analysis.verdict.memoryRegion).toBe("js-heap-exhaustion-confirmed");
80
+ });
81
+
82
+ test("a low free-memory reading alone does not accuse the OS", () => {
83
+ // A small process on a machine whose free pages sit in the file cache, which
84
+ // is the permanent state of affairs on macOS.
85
+ const sessionId = seedSession(dir, { rss: 200 * 1024 * 1024, freeMemory: 1024 * 1024 });
86
+ const analysis = analyzeSession(path.join(dir, `flight-${sessionId}.ndjson`), dir);
87
+ expect(analysis.findings.map((f) => f.rule)).not.toContain("machine-memory-exhausted");
88
+ });
89
+
90
+ test("a process holding most of the machine's memory does accuse the OS", () => {
91
+ const sessionId = seedSession(dir, { rss: 40 * 1024 ** 3, freeMemory: 1024 * 1024 });
92
+ const analysis = analyzeSession(path.join(dir, `flight-${sessionId}.ndjson`), dir);
93
+ expect(analysis.findings.map((f) => f.rule)).toContain("machine-memory-exhausted");
94
+ });
95
+ });
96
+
97
+ describe("instrumentation through to the report", () => {
98
+ test("a cross join followed by an unbounded fetch is attributed to its block", async () => {
99
+ const recorder = openRecorder({ dir, meta: { appVersion: "1.42.0" } });
100
+ const registry = createHandleRegistry();
101
+
102
+ const modelDriver = wrapModelDriver(fakeModelDriver(), recorder, registry);
103
+ const dataDriver = wrapDataDriver(
104
+ {
105
+ getShape: async (_handle: string) => ({ rows: 921_600_000, columns: 2 }),
106
+ getData: async (
107
+ _handle: string,
108
+ _columnIndices: number[],
109
+ _range?: { offset: number; length: number },
110
+ ) => [{ type: "String", data: ["x"] }],
111
+ calculateTableData: async (_handle: string, _request: unknown) => [],
112
+ },
113
+ recorder,
114
+ registry,
115
+ );
116
+
117
+ await recordModelRender(
118
+ recorder,
119
+ { blockId: "block-clonotype-table-7", getStats: () => ({ serOutBytes: 1_204_880 }) },
120
+ async () => {
121
+ const handle = modelDriver.createPTable(crossJoinDef());
122
+ await dataDriver.getShape(handle);
123
+ await dataDriver.getData(handle, [0, 1], undefined);
124
+ },
125
+ );
126
+
127
+ const analysis = analyzeSession(recorder.file, dir);
128
+ const rules = analysis.findings.map((finding) => finding.rule);
129
+ expect(rules).toContain("cross-join");
130
+ expect(rules).toContain("join-amplification");
131
+ expect(rules).toContain("unbounded-getData");
132
+ expect(analysis.renders[0].blockId).toBe("block-clonotype-table-7");
133
+ expect(analysis.renders[0].stats?.serOutBytes).toBe(1_204_880);
134
+
135
+ const report = renderReport(analysis);
136
+ expect(report).toContain("Verdict — cross-join");
137
+ expect(report).toContain("block-clonotype-table-7");
138
+ expect(report).toContain("DISJOINT");
139
+ // The join that produced the failing handle is the one rendered.
140
+ expect(report).toContain("rowsUpperBound=921,600,000");
141
+ });
142
+
143
+ test("an unfinished getUniqueValues is the operation in flight", async () => {
144
+ const recorder = openRecorder({ dir });
145
+ const dataDriver = wrapDataDriver(
146
+ {
147
+ getShape: async (_handle: string) => ({ rows: 1, columns: 1 }),
148
+ getData: async (_handle: string, _columnIndices: number[]) => [],
149
+ calculateTableData: async (_handle: string, _request: unknown) => [],
150
+ // Stands in for the engine dying mid-call: the promise never settles,
151
+ // so no end record is ever written.
152
+ getUniqueValues: (_handle: string, _request: unknown) => new Promise<never>(() => {}),
153
+ findColumns: async (_handle: string, _request: unknown) => ({ hits: [] }),
154
+ },
155
+ recorder,
156
+ createHandleRegistry(),
157
+ );
158
+ const secret = "CASSLGQGAETQYF";
159
+ void dataDriver.getUniqueValues("t1", {
160
+ columnId: "col",
161
+ axis: { type: "String", name: "pl7.app/vdj/clonotypeKey" },
162
+ filters: [{ predicate: { operator: "Equal", reference: secret } }],
163
+ });
164
+ await new Promise((resolve) => setImmediate(resolve));
165
+
166
+ const analysis = analyzeSession(recorder.file, dir);
167
+ expect(analysis.inFlightAtDeath?.op).toBe("getUniqueValues");
168
+ expect(fs.readFileSync(recorder.file, "utf8")).not.toContain(secret);
169
+ });
170
+
171
+ test("a digest failure does not stop the join from being built", () => {
172
+ const recorder = openRecorder({ dir });
173
+ const modelDriver = wrapModelDriver(fakeModelDriver(), recorder, createHandleRegistry());
174
+ // `src` is a getter that throws, standing in for any shape the digest
175
+ // cannot walk. The handle must still come back.
176
+ const hostile = {
177
+ get src(): never {
178
+ throw new Error("unwalkable def");
179
+ },
180
+ partitionFilters: [],
181
+ filters: [],
182
+ sorting: [],
183
+ };
184
+ expect(modelDriver.createPTable(hostile)).toBe("t1");
185
+ const analysis = analyzeSession(recorder.file, dir);
186
+ expect(analysis.recordCount).toBeGreaterThan(1);
187
+ });
188
+ });
189
+
190
+ describe("review findings", () => {
191
+ test("a death inside a synchronous creation call is attributed to that call", () => {
192
+ const recorder = openRecorder({ dir });
193
+ const modelDriver = wrapModelDriver(
194
+ {
195
+ createPFrame: () => "f1",
196
+ // Stands in for the native engine dying mid-call: the process is gone
197
+ // before the end record could be written.
198
+ createPTable: (): string => {
199
+ throw Object.assign(new Error("simulated hard death"), { hard: true });
200
+ },
201
+ createPTableV2: () => "t2",
202
+ } as FakeModelDriver,
203
+ recorder,
204
+ createHandleRegistry(),
205
+ );
206
+ recorder.event("render-begin", { blockId: "block-7" });
207
+ // Truncate the log right after the begin record, the way a kill would.
208
+ const before = fs.statSync(recorder.file).size;
209
+ try {
210
+ modelDriver.createPTable(crossJoinDef());
211
+ } catch {
212
+ // expected
213
+ }
214
+ const content = fs.readFileSync(recorder.file, "utf8");
215
+ const beginLine = content.split("\n").find((l) => l.includes('"createPTable-begin"'))!;
216
+ fs.writeFileSync(recorder.file, `${content.slice(0, before)}${beginLine}\n`);
217
+
218
+ const analysis = analyzeSession(recorder.file, dir);
219
+ expect(analysis.inFlightAtDeath?.op).toBe("createPTable");
220
+ expect(analysis.verdict.where).toContain("createPTable");
221
+ expect(analysis.verdict.where).toContain("block-7");
222
+ expect(renderReport(analysis)).toContain("Definition in flight");
223
+ expect(renderReport(analysis)).toContain("DISJOINT");
224
+ });
225
+
226
+ test("a rotated session keeps its header and pairs operations across segments", () => {
227
+ const recorder = openRecorder({ dir, maxFileBytes: 2000, meta: { appVersion: "rot" } });
228
+ recorder.event("getShape-begin", { handle: "t1", mem: recorder.memorySnapshot() });
229
+ // Fill until exactly one rotation has happened, whatever the record sizes are
230
+ // on this machine; a second rotation would discard the parked segment.
231
+ while (!fs.existsSync(`${recorder.file}.1`)) {
232
+ recorder.event("mem-self", { mem: recorder.memorySnapshot() });
233
+ }
234
+ recorder.event("getShape-end", {
235
+ begin: 2,
236
+ rows: 5,
237
+ columns: 1,
238
+ mem: recorder.memorySnapshot(),
239
+ });
240
+ recorder.event("getData-begin", { handle: "t1" });
241
+
242
+ expect(fs.existsSync(`${recorder.file}.1`)).toBe(true);
243
+ const active = fs.readFileSync(recorder.file, "utf8");
244
+ expect(active).toContain('"continuation":true');
245
+
246
+ const analysis = analyzeSession(recorder.file, dir);
247
+ expect(analysis.env?.node).toBe(process.version);
248
+ expect(analysis.meta).toEqual({ appVersion: "rot" });
249
+ // The loss is reported rather than implied.
250
+ expect(analysis.rotations).toBe(1);
251
+ expect(renderReport(analysis)).toContain("Log rotations");
252
+ // getShape began before rotation and ended after it; only getData is open.
253
+ expect(analysis.inFlight.map((op) => op.op)).toEqual(["getData"]);
254
+ expect(analysis.attribution.some((op) => op.op === "getShape")).toBe(true);
255
+ });
256
+
257
+ test("an open begin survives repeated rotation, so the in-flight call is still named", () => {
258
+ const recorder = openRecorder({ dir, maxFileBytes: 1800, meta: { appVersion: "carry" } });
259
+ // These two never end. Their begin records are written before any rotation
260
+ // and must still be there after the earliest segment has been overwritten.
261
+ recorder.event("render-begin", { blockId: "block-7", mem: recorder.memorySnapshot() });
262
+ recorder.event("createPTable-begin", {
263
+ def: { kind: "PTableDef", def: { type: "inner" } },
264
+ mem: recorder.memorySnapshot(),
265
+ });
266
+
267
+ rotateUntilEarliestSegmentLost(recorder);
268
+
269
+ const analysis = analyzeSession(recorder.file, dir);
270
+ expect(analysis.rotations).toBeGreaterThan(1);
271
+ // Carried-forward begins repeat their sequence number and must not be
272
+ // counted as separate operations.
273
+ expect(analysis.inFlight.map((op) => `${op.op}#${op.seq}`)).toEqual([
274
+ "render#2",
275
+ "createPTable#3",
276
+ ]);
277
+ expect(analysis.inFlightAtDeath?.op).toBe("createPTable");
278
+ expect(analysis.verdict.where).toContain("createPTable");
279
+ expect(analysis.verdict.where).toContain("block-7");
280
+ });
281
+
282
+ test("a render that spans rotation stops attributing calls once it has returned", () => {
283
+ const recorder = openRecorder({ dir, maxFileBytes: 1800 });
284
+ const render = recorder.event("render-begin", {
285
+ blockId: "block-early",
286
+ mem: recorder.memorySnapshot(),
287
+ });
288
+ // The begin is carried into the new segment, so the log holds two copies of
289
+ // it under one sequence number.
290
+ rotateUntilEarliestSegmentLost(recorder);
291
+ recorder.event("render-end", { begin: render, mem: recorder.memorySnapshot() });
292
+
293
+ // This call belongs to no render at all: the only one is over.
294
+ recorder.event("createPTable-begin", {
295
+ def: { kind: "PTableDef", def: crossJoinDef() },
296
+ mem: recorder.memorySnapshot(),
297
+ });
298
+
299
+ const analysis = analyzeSession(recorder.file, dir);
300
+ expect(analysis.inFlight.map((op) => op.op)).toEqual(["createPTable"]);
301
+ const crossJoin = analysis.findings.find((f) => f.rule === "cross-join");
302
+ expect(crossJoin).toBeDefined();
303
+ expect(crossJoin?.block).toBeUndefined();
304
+ });
305
+
306
+ test("the earliest memory reading survives rotation, so growth is not understated", () => {
307
+ const recorder = openRecorder({ dir, maxFileBytes: 1800 });
308
+ recorder.event("getData-begin", { handle: "t1", mem: recorder.memorySnapshot() });
309
+ rotateUntilEarliestSegmentLost(recorder);
310
+
311
+ const analysis = analyzeSession(recorder.file, dir);
312
+ // Without the carried baseline the heap series would start mid-session while
313
+ // the sampler's resident series still starts at zero, which biases the
314
+ // classifier toward blaming native memory.
315
+ expect(fs.readFileSync(recorder.file, "utf8")).toContain('"mem-baseline"');
316
+ expect(analysis.memory.heapGrowth).toBeDefined();
317
+ });
318
+
319
+ test("a completed operation is still paired when its begin was carried forward", () => {
320
+ const recorder = openRecorder({ dir, maxFileBytes: 1800 });
321
+ const begin = recorder.event("getShape-begin", {
322
+ handle: "t1",
323
+ mem: recorder.memorySnapshot(),
324
+ });
325
+ rotateUntilEarliestSegmentLost(recorder);
326
+ recorder.event("getShape-end", { begin, rows: 5, mem: recorder.memorySnapshot() });
327
+
328
+ const analysis = analyzeSession(recorder.file, dir);
329
+ expect(analysis.inFlight).toEqual([]);
330
+ expect(analysis.attribution.some((op) => op.op === "getShape")).toBe(true);
331
+ });
332
+
333
+ test("a render open across a rotation is listed once, and as finished", () => {
334
+ const recorder = openRecorder({ dir, maxFileBytes: 2000 });
335
+ const begin = recorder.event("render-begin", {
336
+ blockId: "block-7",
337
+ mem: recorder.memorySnapshot(),
338
+ });
339
+ while (!fs.existsSync(`${recorder.file}.1`)) {
340
+ recorder.event("mem-self", { mem: recorder.memorySnapshot() });
341
+ }
342
+ recorder.event("render-end", { begin, ms: 5, mem: recorder.memorySnapshot() });
343
+
344
+ const analysis = analyzeSession(recorder.file, dir);
345
+ const renders = analysis.renders.filter((render) => render.blockId === "block-7");
346
+ expect(renders).toHaveLength(1);
347
+ expect(renders[0].end).toBe(true);
348
+ expect(analysis.inFlight).toEqual([]);
349
+ });
350
+
351
+ test("a crash marker names its session and never attaches to a clean or unrelated one", () => {
352
+ const clean = openRecorder({ dir });
353
+ clean.event("getData-begin", { handle: "t1" });
354
+ clean.close();
355
+
356
+ const older = openRecorder({ dir });
357
+ older.event("getData-begin", { handle: "t1" });
358
+ // older never closes: it died, but nobody wrote a marker for it.
359
+
360
+ const newer = openRecorder({ dir });
361
+ newer.event("getData-begin", { handle: "t2" });
362
+ writeCrashMarker(dir, {
363
+ error: Object.assign(new Error("Worker terminated"), { code: "ERR_WORKER_OUT_OF_MEMORY" }),
364
+ });
365
+
366
+ // Two sessions look dead, so the marker names neither and says so.
367
+ expect(analyzeSession(clean.file, dir).crashMarker).toBeUndefined();
368
+ const olderAnalysis = analyzeSession(older.file, dir);
369
+ expect(olderAnalysis.crashMarker).toBeUndefined();
370
+ expect(olderAnalysis.findings.map((f) => f.rule)).toContain("unattributed-crash-marker");
371
+ });
372
+
373
+ test("an assigned session id binds the marker even while a newer session is live", () => {
374
+ const dying = openRecorder({ dir });
375
+ dying.event("getData-begin", { handle: "t1" });
376
+ const live = openRecorder({ dir });
377
+ live.event("getShape-begin", { handle: "t2" });
378
+ // The live session keeps appending after the worker died — it is the newer file.
379
+ writeCrashMarker(dir, {
380
+ sessionId: dying.sessionId,
381
+ error: Object.assign(new Error("Worker terminated"), { code: "ERR_WORKER_OUT_OF_MEMORY" }),
382
+ });
383
+ live.event("getShape-end", { begin: 2, mem: live.memorySnapshot() });
384
+
385
+ expect(analyzeSession(dying.file, dir).crashMarker?.sessionId).toBe(dying.sessionId);
386
+ expect(analyzeSession(dying.file, dir).verdict.memoryRegion).toBe(
387
+ "js-heap-exhaustion-confirmed",
388
+ );
389
+ expect(analyzeSession(live.file, dir).crashMarker).toBeUndefined();
390
+ });
391
+
392
+ test("a marker with no assigned id goes to the session that stopped writing", () => {
393
+ const dying = openRecorder({ dir });
394
+ dying.event("getData-begin", { handle: "t1" });
395
+ const live = openRecorder({ dir });
396
+ live.event("getShape-begin", { handle: "t2" });
397
+ // No id handed over. The newest open log at this moment is the live
398
+ // session's, so a guess would name the wrong one.
399
+ writeCrashMarker(dir, {
400
+ error: Object.assign(new Error("Worker terminated"), { code: "ERR_WORKER_OUT_OF_MEMORY" }),
401
+ });
402
+ const guess = readCrashMarkers(dir)[0];
403
+ expect(guess.sessionId).toBeUndefined();
404
+ expect(guess.guessedSessionId).toBe(live.sessionId);
405
+
406
+ // The live session carries on well past the marker, which is what separates
407
+ // it from the one that died.
408
+ fs.appendFileSync(
409
+ live.file,
410
+ `${JSON.stringify({ seq: 99, t: 0, wall: Date.now() + 60_000, type: "mem-self" })}\n`,
411
+ );
412
+
413
+ expect(analyzeSession(live.file, dir).crashMarker).toBeUndefined();
414
+ // The guess named the live session, yet the death is attributed to the one
415
+ // that actually stopped writing.
416
+ expect(analyzeSession(dying.file, dir).crashMarker?.reason).toBe("js-heap-out-of-memory");
417
+ });
418
+
419
+ test("two sessions that both look dead leave the marker unattributed", () => {
420
+ const first = openRecorder({ dir });
421
+ first.event("getData-begin", { handle: "t1" });
422
+ const second = openRecorder({ dir });
423
+ second.event("getData-begin", { handle: "t2" });
424
+ // Neither closes and neither writes again, so timing cannot tell them apart.
425
+ writeCrashMarker(dir, { reason: "killed-by-os" });
426
+
427
+ for (const session of [first, second]) {
428
+ const analysis = analyzeSession(session.file, dir);
429
+ expect(analysis.crashMarker).toBeUndefined();
430
+ expect(analysis.findings.map((f) => f.rule)).toContain("unattributed-crash-marker");
431
+ }
432
+ });
433
+
434
+ test("a legacy marker without a session id is bounded by the next session's start", () => {
435
+ const first = openRecorder({ dir });
436
+ first.event("getData-begin", { handle: "t1" });
437
+ const firstLast = Date.now();
438
+ // Legacy marker: written for `first`, carries no session id.
439
+ fs.writeFileSync(
440
+ path.join(dir, `crash-${firstLast + 1}.ndjson`),
441
+ `${JSON.stringify({ type: "external-crash", wall: firstLast + 1, reason: "killed-by-os" })}\n`,
442
+ );
443
+ // A second session starts strictly later.
444
+ const secondStart = firstLast + 10;
445
+ const secondFile = path.join(dir, `flight-${secondStart}-1-abcdef.ndjson`);
446
+ fs.writeFileSync(
447
+ secondFile,
448
+ `${JSON.stringify({ seq: 1, t: 0, wall: secondStart, type: "session", env: {} })}\n` +
449
+ `${JSON.stringify({ seq: 2, t: 1, wall: secondStart + 1, type: "getData-begin" })}\n`,
450
+ );
451
+
452
+ expect(analyzeSession(first.file, dir).crashMarker?.reason).toBe("killed-by-os");
453
+ expect(analyzeSession(secondFile, dir).crashMarker).toBeUndefined();
454
+ });
455
+ });
456
+
457
+ // Internals
458
+
459
+ /**
460
+ * Fills the log until the earliest segment has been overwritten: the parked
461
+ * segment is itself a rotated one, which is the case the carry-forward exists
462
+ * for. Counting headers in the active file would not do — the preamble is
463
+ * rewritten from scratch on every rotation, so it always holds exactly one.
464
+ */
465
+ function rotateUntilEarliestSegmentLost(recorder: Recorder): void {
466
+ const parked = `${recorder.file}.1`;
467
+ for (let guard = 0; guard < 20_000; guard++) {
468
+ recorder.event("mem-self", { mem: recorder.memorySnapshot() });
469
+ if (fs.existsSync(parked) && fs.readFileSync(parked, "utf8").includes('"continuation":true')) {
470
+ return;
471
+ }
472
+ }
473
+ throw new Error("log did not rotate twice");
474
+ }
475
+
476
+ type FakeModelDriver = {
477
+ createPFrame(def: unknown): string;
478
+ createPTable(def: unknown): string;
479
+ createPTableV2(def: unknown): string;
480
+ };
481
+
482
+ function fakeModelDriver(): FakeModelDriver {
483
+ return {
484
+ createPFrame: () => "f1",
485
+ createPTable: () => "t1",
486
+ createPTableV2: () => "t2",
487
+ };
488
+ }
489
+
490
+ function crossJoinDef(): unknown {
491
+ const column = (name: string, axisName: string, rows: number) => ({
492
+ type: "column",
493
+ column: {
494
+ id: `id-${name}`,
495
+ spec: {
496
+ kind: "PColumn",
497
+ name,
498
+ valueType: "Int",
499
+ axesSpec: [{ type: "String", name: axisName }],
500
+ },
501
+ data: {
502
+ type: "ParquetPartitioned",
503
+ partitionKeyLength: 1,
504
+ parts: { "[0]": { data: "b", stats: { numberOfRows: rows } } },
505
+ },
506
+ },
507
+ });
508
+ return {
509
+ src: {
510
+ type: "inner",
511
+ entries: [
512
+ column("perSample", "pl7.app/sampleId", 384),
513
+ column("perClonotype", "pl7.app/vdj/clonotypeKey", 2_400_000),
514
+ ],
515
+ },
516
+ partitionFilters: [],
517
+ filters: [],
518
+ sorting: [],
519
+ };
520
+ }
521
+
522
+ /** Writes a minimal crashed session plus a sampler series with chosen numbers. */
523
+ function seedSession(dir: string, sample: { rss: number; freeMemory: number }): string {
524
+ const recorder = openRecorder({ dir });
525
+ recorder.event("getData-begin", { handle: "t1" });
526
+ const sessionId = recorder.sessionId;
527
+ const line = JSON.stringify({
528
+ seq: 1,
529
+ t: 1,
530
+ wall: Date.now(),
531
+ type: "mem-sampler",
532
+ rss: sample.rss,
533
+ peakRss: sample.rss,
534
+ freeMemory: sample.freeMemory,
535
+ totalMemory: 48 * 1024 ** 3,
536
+ });
537
+ fs.writeFileSync(path.join(dir, `mem-${sessionId}.ndjson`), `${line}\n`);
538
+ return sessionId;
539
+ }