@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
package/README.md ADDED
@@ -0,0 +1,119 @@
1
+ # @milaboratories/pl-flight-recorder
2
+
3
+ Explains an out-of-memory death in the block model layer after the fact, when
4
+ the process is already gone and the data that caused it cannot be obtained.
5
+
6
+ ## Why it is built this way
7
+
8
+ The failure this exists for kills the process in seconds and runs no shutdown
9
+ path: V8 fatal out-of-memory, a failed native allocation, the OS out-of-memory
10
+ killer. That rules out anything that collects on demand from a live process —
11
+ `pprofDump`, cache metrics, a log that flushes on exit — because by the time
12
+ anyone asks, there is nothing to ask.
13
+
14
+ Three consequences shape the design:
15
+
16
+ - **Records are appended synchronously.** A buffered stream loses exactly the
17
+ tail that explains the death. The absence of a terminating `session-end`
18
+ record is how a crash is detected.
19
+ - **Memory is sampled from another thread.** While the middle layer sits inside
20
+ a synchronous pframes call its own timers do not fire, so its memory series
21
+ goes dark precisely when memory is growing fastest.
22
+ - **The parent records the cause.** A thread that runs out of heap cannot
23
+ describe its own death; its last reading predates the blow-up. Node reports
24
+ `ERR_WORKER_OUT_OF_MEMORY` to the parent, which turns a guess into a fact.
25
+
26
+ ## Enabling it
27
+
28
+ Inert unless `MI_FLIGHT_RECORDER_DIR` points at a directory. Recording appends
29
+ on every recorded operation and that cost has not yet been measured against a
30
+ real project, so it is opt-in rather than on by default.
31
+
32
+ ```
33
+ MI_FLIGHT_RECORDER_DIR=~/platforma-flight <start the app>
34
+ ```
35
+
36
+ ## Reading a log
37
+
38
+ ```ts
39
+ import { analyzeLatest, renderReport } from "@milaboratories/pl-flight-recorder";
40
+
41
+ const analysis = analyzeLatest(dir); // prefers the session that crashed
42
+ console.log(renderReport(analysis));
43
+ ```
44
+
45
+ The verdict combines three independent lines of evidence, none sufficient alone:
46
+
47
+ 1. **What was in flight.** Every operation writes a begin and an end record, so
48
+ an unmatched begin names the exact call that was running when memory ran out.
49
+ 2. **Which memory ran out.** JS heap (confirmed by the parent), off-heap
50
+ ArrayBuffers handed out by the engine, native allocation, or the machine.
51
+ This decides whether `--max-old-space-size` is even relevant.
52
+ 3. **Why the data was that big.** Structural faults readable from the recorded
53
+ shape before any data is touched — a cartesian join, or axes that agree on
54
+ name and type but differ in domain — plus the observed row count against the
55
+ input the definition declared.
56
+
57
+ ## What is recorded, and how
58
+
59
+ A definition is recorded by **shape**, not by a hand-written digest per
60
+ definition type. `redact` walks whatever the seam was handed and rebuilds it:
61
+ keys, numbers and the small set of strings that are schema survive verbatim,
62
+ every other string becomes a hash and a length, and a column payload is replaced
63
+ by counts. The default for an unrecognised string is to hash it, so a field this
64
+ code has never seen can make a report less informative but never make it leak.
65
+
66
+ - Kept: definition shape, column and axis names, value types, axis domains
67
+ (they carry join identity), filter operators, row/byte/partition counts.
68
+ - Hashed: filter reference values, annotation values (`pl7.app/label` carries
69
+ user-entered sample names), column ids, every other string. The hash is
70
+ stable, so two labels can be compared without either being revealed.
71
+ - Dropped: cell values, inline column payloads, partition keys.
72
+
73
+ Two things bound one record: arrays keep their head and carry an `$omitted`
74
+ marker, and a node budget stops a pathological definition from filling the log.
75
+ A session's log is bounded the same way, at two segments of 32 MiB. Rotation
76
+ rewrites into the new segment the three things a report cannot be produced
77
+ without — the session header, the earliest memory reading, and the begin record
78
+ of every operation still open — so repeated rotation costs only operations that
79
+ already completed. The report states how many rotations happened.
80
+ Class instances are named (`$opaque`) rather than walked, which matters because
81
+ a definition can carry live accessors that reference each other.
82
+
83
+ On a realistic definition (two columns, 72 partitions, a 1200-value filter) the
84
+ record is ~1 KB against ~23 KB for the definition as it stands — but size is not
85
+ why this exists. The definition as it stands cannot be sent to us at all.
86
+
87
+ Only one module knows a definition's types: `data_summary`, which reads row
88
+ counts and byte sizes out of `DataInfo` because those numbers live at
89
+ type-specific positions and they are what predicts a join's cost.
90
+
91
+ ## Rules run in the analyzer
92
+
93
+ Structural rules are not evaluated while recording. Nothing is computed on the
94
+ hot path, the rules can be revised against logs that already exist, and one
95
+ implementation covers every definition shape: join nodes are recognised by their
96
+ discriminator and their children by position, so the original tree API and the
97
+ V2 query API are read by the same walk.
98
+
99
+ ## Where it hooks in
100
+
101
+ - `pl-middle-layer/src/middle_layer/driver_kit.ts` wraps the pFrame driver once.
102
+ Every join a model builds and every row it reads passes through it, so both
103
+ the creation calls and the data calls are covered without touching call sites.
104
+ - `pl-middle-layer/src/js_render/index.ts` opens a render span per block, and one
105
+ per resumption of a deferred render. Driver calls carry no block identity of
106
+ their own, so the enclosing span is what attributes a join to a block.
107
+
108
+ `executeSingleLambda` is deliberately not instrumented: it evaluates `args()`
109
+ style lambdas with no computable context, so it reaches no driver.
110
+
111
+ ## Known gaps
112
+
113
+ - The synchronous append cost is unmeasured on a real project; the sampler
114
+ interval and event granularity should be revisited with that number in hand.
115
+ - A table view can die in the renderer process rather than in the middle-layer
116
+ worker. That path needs `render-process-gone` in the desktop app and is not
117
+ covered here.
118
+ - Domain values are kept because they carry join identity. If any producer puts
119
+ a sample name in a domain value, it needs hashing too.
@@ -0,0 +1,133 @@
1
+ import { CrashMarker, SessionEnvironment } from "./events.js";
2
+ import { FindingSeverity } from "./rules.js";
3
+ //#region src/analyze.d.ts
4
+ /**
5
+ * Turns a flight log into an attributed cause.
6
+ *
7
+ * Three independent lines of evidence are combined: which operation was still
8
+ * running when the process died (an unmatched begin record), where memory
9
+ * actually went (resident growth across each completed operation, and which
10
+ * region grew — JS heap, off-heap buffers, or native), and what the join tree
11
+ * looked like before any data was touched. Any one alone is suggestive;
12
+ * together they name a specific call in a specific block.
13
+ */
14
+ export declare const THRESHOLDS: {
15
+ /** Fraction of the heap ceiling above which the heap counts as exhausted. */
16
+ readonly heapPressure: 0.85;
17
+ readonly nativeGrowthBytes: number;
18
+ readonly amplification: 10;
19
+ readonly unboundedRows: 1000000;
20
+ readonly returnedBytes: number;
21
+ readonly inlineEntries: 1000000;
22
+ readonly stallMs: 2000;
23
+ /** Backward tolerance when matching a marker by time, for parent/worker clock drift. */
24
+ readonly clockToleranceMs: 1000;
25
+ /** How many open sessions are considered as rivals for an unattributed marker. */
26
+ readonly maxRivalSessions: 8;
27
+ /** A machine-memory claim needs the process to actually be large. */
28
+ readonly machineRssShare: 0.25;
29
+ };
30
+ export type Finding = {
31
+ rule: string;
32
+ severity: FindingSeverity;
33
+ detail: string;
34
+ seq?: number;
35
+ path?: string;
36
+ join?: string;
37
+ source?: string;
38
+ block?: string;
39
+ };
40
+ export type MemoryAnalysis = {
41
+ samplerPresent: boolean;
42
+ sampleCount: number;
43
+ rssSeries: {
44
+ wall: number;
45
+ rss?: number;
46
+ freeMemory?: number;
47
+ }[];
48
+ peakRss: number;
49
+ rssAtDeath?: number;
50
+ rssGrowth: number;
51
+ heapUsedAtDeath?: number;
52
+ heapLimit?: number;
53
+ heapPressure?: number;
54
+ heapGrowth?: number;
55
+ externalGrowth?: number;
56
+ arrayBuffersGrowth?: number;
57
+ worstStallMs: number;
58
+ freeMemoryAtDeath?: number;
59
+ totalMemory?: number;
60
+ };
61
+ export type OperationSummary = {
62
+ op: string;
63
+ seq: number;
64
+ wall: number;
65
+ info: Record<string, unknown>;
66
+ end?: Record<string, unknown>;
67
+ ms?: number;
68
+ failed?: boolean;
69
+ rssDelta?: number;
70
+ heapDelta?: number;
71
+ };
72
+ export type RenderSummary = {
73
+ seq: number;
74
+ blockId?: string;
75
+ block?: string;
76
+ key?: string;
77
+ end?: boolean;
78
+ failed?: boolean;
79
+ ms?: number;
80
+ stats?: {
81
+ serOutBytes?: number;
82
+ serInBytes?: number;
83
+ [key: string]: unknown;
84
+ };
85
+ };
86
+ export type Verdict = {
87
+ outcome: string;
88
+ peakRss: number;
89
+ where: string;
90
+ memoryRegion?: string;
91
+ likelyCause?: string;
92
+ summary: string;
93
+ };
94
+ export type SessionAnalysis = {
95
+ file: string;
96
+ sessionId: string;
97
+ crashed: boolean;
98
+ truncatedTail: boolean;
99
+ endedReason?: string;
100
+ crashMarker?: CrashMarker;
101
+ env?: SessionEnvironment;
102
+ role?: string;
103
+ meta?: Record<string, unknown>;
104
+ recordCount: number;
105
+ /**
106
+ * How many times the log rotated. Each rotation re-emits the session header,
107
+ * so environment and metadata survive, but operations older than the retained
108
+ * segments do not — which is why the count is reported rather than implied.
109
+ */
110
+ rotations: number;
111
+ memory: MemoryAnalysis;
112
+ /** Completed operations ranked by resident growth. */
113
+ attribution: OperationSummary[];
114
+ inFlight: OperationSummary[];
115
+ /** The innermost operation that started and never returned. */
116
+ inFlightAtDeath?: OperationSummary;
117
+ renders: RenderSummary[];
118
+ findings: Finding[];
119
+ verdict: Verdict;
120
+ timeline: Record<string, unknown>[];
121
+ };
122
+ /** Analyzes the newest crashed session in a directory, else the newest session. */
123
+ export declare function analyzeLatest(dir: string, options?: {
124
+ preferCrashed?: boolean;
125
+ }): SessionAnalysis | undefined;
126
+ /** Analyzes one flight log, merging the sibling sampler series when present. */
127
+ export declare function analyzeSession(file: string, dir?: string): SessionAnalysis;
128
+ /** Thousands separators, or `unknown` when the count was never observed. */
129
+ export declare function formatCount(value: number | undefined): string;
130
+ /** Binary byte units, or `unknown`. */
131
+ export declare function formatBytes(value: number | undefined): string;
132
+ //#endregion
133
+ //# sourceMappingURL=analyze.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"analyze.d.ts","names":[],"sources":["../src/analyze.ts"],"mappings":";;;;;;;;;;;;;qBAwBa;;WAEX;WACA;WACA;WACA;WACA;WACA;WACA;;WAEA;;WAEA;;WAEA;;YAGU;EACV;EACA,UAAU;EACV;EACA;EACA;EACA;EACA;EACA;;YAGU;EACV;EACA;EACA;IAAa;IAAc;IAAc;;EACzC;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;YAGU;EACV;EACA;EACA;EACA,MAAM;EACN,MAAM;EACN;EACA;EACA;EACA;;YAGU;EACV;EACA;EACA;EACA;EACA;EACA;EACA;EACA;IAAU;IAAsB;KAAsB;;;YAG5C;EACV;EACA;EACA;EACA;EACA;EACA;;YAGU;EACV;EACA;EACA;EACA;EACA;EACA,cAAc;EACd,MAAM;EACN;EACA,OAAO;EACP;;;;;;EAMA;EACA,QAAQ;;EAER,aAAa;EACb,UAAU;;EAEV,kBAAkB;EAClB,SAAS;EACT,UAAU;EACV,SAAS;EACT,UAAU;;;wBAII,cACd,aACA;EAAW;IACV;;wBASa,eAAe,cAAc,eAAmC;;wBA+DhE,YAAY;;wBAKZ,YAAY"}