@memlab/mcp-server 2.1.1 → 2.18.1
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 +83 -3
- package/dist/analysis-budget.d.ts +71 -0
- package/dist/analysis-budget.d.ts.map +1 -0
- package/dist/analysis-budget.js +118 -0
- package/dist/analysis-budget.js.map +1 -0
- package/dist/guardrail.d.ts +24 -0
- package/dist/guardrail.d.ts.map +1 -0
- package/dist/guardrail.js +65 -0
- package/dist/guardrail.js.map +1 -0
- package/dist/heap-state.d.ts +53 -1
- package/dist/heap-state.d.ts.map +1 -1
- package/dist/heap-state.js +153 -10
- package/dist/heap-state.js.map +1 -1
- package/dist/index.js +38 -1
- package/dist/index.js.map +1 -1
- package/dist/tools/array-group-by.d.ts +12 -0
- package/dist/tools/array-group-by.d.ts.map +1 -0
- package/dist/tools/array-group-by.js +191 -0
- package/dist/tools/array-group-by.js.map +1 -0
- package/dist/tools/auto-investigate.d.ts.map +1 -1
- package/dist/tools/auto-investigate.js +1056 -52
- package/dist/tools/auto-investigate.js.map +1 -1
- package/dist/tools/cache-analysis.d.ts.map +1 -1
- package/dist/tools/cache-analysis.js +438 -21
- package/dist/tools/cache-analysis.js.map +1 -1
- package/dist/tools/check-health.d.ts.map +1 -1
- package/dist/tools/check-health.js +261 -1
- package/dist/tools/check-health.js.map +1 -1
- package/dist/tools/class-histogram.d.ts.map +1 -1
- package/dist/tools/class-histogram.js +142 -33
- package/dist/tools/class-histogram.js.map +1 -1
- package/dist/tools/closure-inspection.d.ts.map +1 -1
- package/dist/tools/closure-inspection.js +238 -25
- package/dist/tools/closure-inspection.js.map +1 -1
- package/dist/tools/detached-dom.d.ts.map +1 -1
- package/dist/tools/detached-dom.js +25 -3
- package/dist/tools/detached-dom.js.map +1 -1
- package/dist/tools/dev-artifacts.d.ts +33 -0
- package/dist/tools/dev-artifacts.d.ts.map +1 -0
- package/dist/tools/dev-artifacts.js +165 -0
- package/dist/tools/dev-artifacts.js.map +1 -0
- package/dist/tools/diff-snapshots.d.ts.map +1 -1
- package/dist/tools/diff-snapshots.js +200 -48
- package/dist/tools/diff-snapshots.js.map +1 -1
- package/dist/tools/duplicated-strings.d.ts.map +1 -1
- package/dist/tools/duplicated-strings.js +74 -12
- package/dist/tools/duplicated-strings.js.map +1 -1
- package/dist/tools/eval.d.ts.map +1 -1
- package/dist/tools/eval.js +189 -10
- package/dist/tools/eval.js.map +1 -1
- package/dist/tools/event-listener-leaks.d.ts.map +1 -1
- package/dist/tools/event-listener-leaks.js +235 -13
- package/dist/tools/event-listener-leaks.js.map +1 -1
- package/dist/tools/event-registry.d.ts +12 -0
- package/dist/tools/event-registry.d.ts.map +1 -0
- package/dist/tools/event-registry.js +183 -0
- package/dist/tools/event-registry.js.map +1 -0
- package/dist/tools/find-by-property.d.ts.map +1 -1
- package/dist/tools/find-by-property.js +113 -31
- package/dist/tools/find-by-property.js.map +1 -1
- package/dist/tools/find-by-shape.d.ts +12 -0
- package/dist/tools/find-by-shape.d.ts.map +1 -0
- package/dist/tools/find-by-shape.js +178 -0
- package/dist/tools/find-by-shape.js.map +1 -0
- package/dist/tools/find-nodes-by-class.d.ts.map +1 -1
- package/dist/tools/find-nodes-by-class.js +50 -6
- package/dist/tools/find-nodes-by-class.js.map +1 -1
- package/dist/tools/for-each.d.ts.map +1 -1
- package/dist/tools/for-each.js +1 -1
- package/dist/tools/for-each.js.map +1 -1
- package/dist/tools/get-node.d.ts.map +1 -1
- package/dist/tools/get-node.js +6 -0
- package/dist/tools/get-node.js.map +1 -1
- package/dist/tools/get-references.d.ts.map +1 -1
- package/dist/tools/get-references.js +35 -6
- package/dist/tools/get-references.js.map +1 -1
- package/dist/tools/get-referrers.d.ts.map +1 -1
- package/dist/tools/get-referrers.js +35 -6
- package/dist/tools/get-referrers.js.map +1 -1
- package/dist/tools/get-string.d.ts +12 -0
- package/dist/tools/get-string.d.ts.map +1 -0
- package/dist/tools/get-string.js +223 -0
- package/dist/tools/get-string.js.map +1 -0
- package/dist/tools/get-value.d.ts +12 -0
- package/dist/tools/get-value.d.ts.map +1 -0
- package/dist/tools/get-value.js +152 -0
- package/dist/tools/get-value.js.map +1 -0
- package/dist/tools/global-variables.d.ts.map +1 -1
- package/dist/tools/global-variables.js +54 -34
- package/dist/tools/global-variables.js.map +1 -1
- package/dist/tools/growth-signals.d.ts +12 -0
- package/dist/tools/growth-signals.d.ts.map +1 -0
- package/dist/tools/growth-signals.js +211 -0
- package/dist/tools/growth-signals.js.map +1 -0
- package/dist/tools/intern-opportunities.d.ts +12 -0
- package/dist/tools/intern-opportunities.d.ts.map +1 -0
- package/dist/tools/intern-opportunities.js +870 -0
- package/dist/tools/intern-opportunities.js.map +1 -0
- package/dist/tools/largest-objects.d.ts.map +1 -1
- package/dist/tools/largest-objects.js +37 -14
- package/dist/tools/largest-objects.js.map +1 -1
- package/dist/tools/load-snapshot.d.ts +70 -0
- package/dist/tools/load-snapshot.d.ts.map +1 -1
- package/dist/tools/load-snapshot.js +446 -17
- package/dist/tools/load-snapshot.js.map +1 -1
- package/dist/tools/object-cost-breakdown.d.ts +12 -0
- package/dist/tools/object-cost-breakdown.d.ts.map +1 -0
- package/dist/tools/object-cost-breakdown.js +439 -0
- package/dist/tools/object-cost-breakdown.js.map +1 -0
- package/dist/tools/object-shape.d.ts.map +1 -1
- package/dist/tools/object-shape.js +109 -2
- package/dist/tools/object-shape.js.map +1 -1
- package/dist/tools/pinch-points.d.ts.map +1 -1
- package/dist/tools/pinch-points.js +9 -9
- package/dist/tools/pinch-points.js.map +1 -1
- package/dist/tools/property-distribution.d.ts +12 -0
- package/dist/tools/property-distribution.d.ts.map +1 -0
- package/dist/tools/property-distribution.js +168 -0
- package/dist/tools/property-distribution.js.map +1 -0
- package/dist/tools/quick-diagnosis.d.ts.map +1 -1
- package/dist/tools/quick-diagnosis.js +65 -20
- package/dist/tools/quick-diagnosis.js.map +1 -1
- package/dist/tools/retainer-summary.d.ts.map +1 -1
- package/dist/tools/retainer-summary.js +90 -5
- package/dist/tools/retainer-summary.js.map +1 -1
- package/dist/tools/retainer-trace.d.ts.map +1 -1
- package/dist/tools/retainer-trace.js +173 -43
- package/dist/tools/retainer-trace.js.map +1 -1
- package/dist/tools/search-nodes.d.ts.map +1 -1
- package/dist/tools/search-nodes.js +10 -3
- package/dist/tools/search-nodes.js.map +1 -1
- package/dist/tools/search-strings.d.ts +12 -0
- package/dist/tools/search-strings.d.ts.map +1 -0
- package/dist/tools/search-strings.js +182 -0
- package/dist/tools/search-strings.js.map +1 -0
- package/dist/tools/sequence-analysis.d.ts +12 -0
- package/dist/tools/sequence-analysis.d.ts.map +1 -0
- package/dist/tools/sequence-analysis.js +244 -0
- package/dist/tools/sequence-analysis.js.map +1 -0
- package/dist/tools/server-status.d.ts +12 -0
- package/dist/tools/server-status.d.ts.map +1 -0
- package/dist/tools/server-status.js +41 -0
- package/dist/tools/server-status.js.map +1 -0
- package/dist/tools/shape-histogram.d.ts.map +1 -1
- package/dist/tools/shape-histogram.js +61 -16
- package/dist/tools/shape-histogram.js.map +1 -1
- package/dist/tools/sliced-strings.d.ts.map +1 -1
- package/dist/tools/sliced-strings.js +38 -15
- package/dist/tools/sliced-strings.js.map +1 -1
- package/dist/tools/snapshot-header.d.ts +28 -0
- package/dist/tools/snapshot-header.d.ts.map +1 -0
- package/dist/tools/snapshot-header.js +111 -0
- package/dist/tools/snapshot-header.js.map +1 -0
- package/dist/tools/snapshot-summary.d.ts.map +1 -1
- package/dist/tools/snapshot-summary.js +55 -13
- package/dist/tools/snapshot-summary.js.map +1 -1
- package/dist/tools/snapshots.d.ts +12 -0
- package/dist/tools/snapshots.d.ts.map +1 -0
- package/dist/tools/snapshots.js +93 -0
- package/dist/tools/snapshots.js.map +1 -0
- package/dist/tools/stale-collections.d.ts.map +1 -1
- package/dist/tools/stale-collections.js +248 -7
- package/dist/tools/stale-collections.js.map +1 -1
- package/dist/tools/trace-dominators.d.ts.map +1 -1
- package/dist/tools/trace-dominators.js +28 -1
- package/dist/tools/trace-dominators.js.map +1 -1
- package/dist/tools/weakmap-entries.d.ts +12 -0
- package/dist/tools/weakmap-entries.d.ts.map +1 -0
- package/dist/tools/weakmap-entries.js +156 -0
- package/dist/tools/weakmap-entries.js.map +1 -0
- package/dist/utils.d.ts +139 -2
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +448 -35
- package/dist/utils.js.map +1 -1
- package/package.json +3 -3
|
@@ -0,0 +1,870 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
3
|
+
*
|
|
4
|
+
* This source code is licensed under the MIT license found in the
|
|
5
|
+
* LICENSE file in the root directory of this source tree.
|
|
6
|
+
*
|
|
7
|
+
* @format
|
|
8
|
+
* @oncall memory_lab
|
|
9
|
+
*/
|
|
10
|
+
import { z } from 'zod';
|
|
11
|
+
import { getSnapshot, getSnapshotMetadata, getSessionConfig, } from '../heap-state.js';
|
|
12
|
+
import { formatBytes, formatNumber, markdownTable, errorResult, toolResult, } from '../utils.js';
|
|
13
|
+
// V8 splits a single logical object's storage across several internal backing
|
|
14
|
+
// structures: a `system / PropertyArray` (named props once the object grows past
|
|
15
|
+
// the inline slot count), `(object properties)` / `(object elements)` arrays, and
|
|
16
|
+
// `(sliced string)` views. A string reached through ANY of these is still held by
|
|
17
|
+
// ONE logical object via ONE assignment site — interning the property at the parse
|
|
18
|
+
// boundary collapses it (Feedback round 5 §1/§2). Only a referrer that is a
|
|
19
|
+
// genuinely INDEPENDENT user structure (a real Array element, or a property on a
|
|
20
|
+
// different logical object) keeps the per-row instance alive after interning and
|
|
21
|
+
// therefore makes the savings non-capturable. This predicate identifies the
|
|
22
|
+
// former so it is NOT mistaken for the latter.
|
|
23
|
+
function isOwnStorageReferrer(ref) {
|
|
24
|
+
// Hidden/internal edges are V8 wiring of the object's own representation
|
|
25
|
+
// (this covers the "(object properties) (internal)", "(object elements)
|
|
26
|
+
// (internal)", "(sliced string) (internal)", and "Object (internal)" buckets
|
|
27
|
+
// the feedback flagged as ambiguous — all of them are own-storage).
|
|
28
|
+
if (ref.type === 'hidden' || ref.type === 'internal')
|
|
29
|
+
return true;
|
|
30
|
+
const fromName = ref.fromNode.name || '';
|
|
31
|
+
return (fromName === 'system / PropertyArray' ||
|
|
32
|
+
fromName === 'system / SlicedString' ||
|
|
33
|
+
fromName.startsWith('(object properties)') ||
|
|
34
|
+
fromName.startsWith('(object elements)') ||
|
|
35
|
+
fromName.startsWith('(sliced string)'));
|
|
36
|
+
}
|
|
37
|
+
// The canonical parse-boundary intern fix caps at 128 chars (the skill's
|
|
38
|
+
// recommended cap: interning very long, low-cardinality strings costs a large
|
|
39
|
+
// unique-set retention for little collapse). So the "within-load capturable"
|
|
40
|
+
// figure double-counts value that a COMPLIANT fix would skip. Splitting savings
|
|
41
|
+
// at this length lets the headline report cappable (what the 128-char fix
|
|
42
|
+
// reclaims) separately from over-cap (needs an uncapped pool or a different fix)
|
|
43
|
+
// so the reported number matches what the recommended fix actually reclaims
|
|
44
|
+
// (sweep feedback §3).
|
|
45
|
+
const CANONICAL_CAP_CHARS = 128;
|
|
46
|
+
/**
|
|
47
|
+
* Label the array (and, one level up, its owner) that holds a duplicated
|
|
48
|
+
* array-element string, so columnar / rows-as-arrays duplication groups by a
|
|
49
|
+
* meaningful owner shape (e.g. `Query._rows[]`, `Array[][] (columnar rows)`)
|
|
50
|
+
* instead of a bare `Object`. Walks a bounded set of referrers with the
|
|
51
|
+
* non-materializing iterator (never the O(N) `.referrers` getter).
|
|
52
|
+
*/
|
|
53
|
+
function arrayOwnerLabel(arrayNode) {
|
|
54
|
+
const refs = [];
|
|
55
|
+
arrayNode.forEachReferrer(ref => {
|
|
56
|
+
refs.push(ref);
|
|
57
|
+
if (refs.length >= 8)
|
|
58
|
+
return { stop: true };
|
|
59
|
+
});
|
|
60
|
+
for (const ref of refs) {
|
|
61
|
+
const fromName = ref.fromNode.name || '';
|
|
62
|
+
if (ref.type === 'property' || ref.type === 'context') {
|
|
63
|
+
const owner = fromName && fromName !== 'Object' ? fromName : 'Object';
|
|
64
|
+
return `${owner}.${String(ref.name_or_index)}[]`;
|
|
65
|
+
}
|
|
66
|
+
if (ref.type === 'element') {
|
|
67
|
+
// An array held as an element of another array = matrix / columnar rows.
|
|
68
|
+
const owner = fromName && fromName !== 'Object' ? fromName : 'Array';
|
|
69
|
+
return `${owner}[][] (columnar rows)`;
|
|
70
|
+
}
|
|
71
|
+
if ((ref.type === 'hidden' || ref.type === 'internal') &&
|
|
72
|
+
fromName &&
|
|
73
|
+
fromName !== 'Object') {
|
|
74
|
+
return `${fromName}[]`;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return arrayNode.name && arrayNode.name !== 'Object'
|
|
78
|
+
? `${arrayNode.name}[]`
|
|
79
|
+
: 'Array[]';
|
|
80
|
+
}
|
|
81
|
+
// --- Headline-accuracy heuristics (Feedback round 7) ----------------------
|
|
82
|
+
// Both operate purely on a group's already-aggregated counts / extracted shape
|
|
83
|
+
// props — no heap traversal — so they are O(groups), not O(nodes).
|
|
84
|
+
// Low-ROI: strings barely repeat (copies ÷ unique below the floor) AND are long
|
|
85
|
+
// on average. Interning needs a pool holding the whole unique set for a small
|
|
86
|
+
// collapse — high memory cost, low payoff (e.g. a ~141-char value at 2.2×).
|
|
87
|
+
const LOW_ROI_DUP_FLOOR = 3;
|
|
88
|
+
const LOW_ROI_AVG_BYTES = 128;
|
|
89
|
+
function isLowRoiGroup(uniqueStrings, totalCopies, totalSize) {
|
|
90
|
+
if (uniqueStrings <= 0 || totalCopies <= 0)
|
|
91
|
+
return false;
|
|
92
|
+
const dupFactor = totalCopies / uniqueStrings;
|
|
93
|
+
const avgBytes = totalSize / totalCopies;
|
|
94
|
+
return dupFactor < LOW_ROI_DUP_FLOOR && avgBytes > LOW_ROI_AVG_BYTES;
|
|
95
|
+
}
|
|
96
|
+
// Report a `(concatenated string)` (rope) buildup — string accumulation, not
|
|
97
|
+
// value duplication, which interning cannot help — once this many such nodes are
|
|
98
|
+
// present. Shared by the empty-results path and the main results header so the
|
|
99
|
+
// two call sites cannot drift out of sync.
|
|
100
|
+
const CONCAT_STRING_BUILDUP_FLOOR = 1_000_000;
|
|
101
|
+
// Framework/infra-owned: request headers, cookies, auth tokens, and Next.js
|
|
102
|
+
// URL/cache context. Matched by property name and by tell-tale property names in
|
|
103
|
+
// the parent shape.
|
|
104
|
+
// NOTE: deliberately excludes the generic single-word `via` — though HTTP `Via`
|
|
105
|
+
// is a real header, `via` is a plausible application property name and an exact
|
|
106
|
+
// match would misclassify app data as framework-owned. Real `Via` headers are
|
|
107
|
+
// still caught by the `x-`/`sec-` prefixes and the parent-shape header-bag check.
|
|
108
|
+
// The `.+_oauth_token` / `.+-access-token` alternatives intentionally match any
|
|
109
|
+
// non-empty prefix: a property name ending in those suffixes is auth-token data
|
|
110
|
+
// regardless of prefix. `.+` (not `.*`) keeps bare `_oauth_token` /
|
|
111
|
+
// `-access-token` from matching.
|
|
112
|
+
const FRAMEWORK_PROP_RE = /^(cookie|set-cookie|user-agent|accept-language|accept-encoding|referer|referrer|x-[a-z0-9-]+|sec-[a-z0-9-]+|proxied_to_master|.+_oauth_token|.+-access-token)$/i;
|
|
113
|
+
const FRAMEWORK_SHAPE_NAME_RE = /^(URLContext|IncomingMessage|ServerResponse)$/;
|
|
114
|
+
const FRAMEWORK_SHAPE_PROPS = new Set([
|
|
115
|
+
'cookie',
|
|
116
|
+
'user-agent',
|
|
117
|
+
'accept-language',
|
|
118
|
+
'x-fb-validated-client-cert',
|
|
119
|
+
'proxied_to_master',
|
|
120
|
+
'intern_oauth_token',
|
|
121
|
+
]);
|
|
122
|
+
function isFrameworkOwned(propertyName, parentShape, parentShapeProps) {
|
|
123
|
+
if (FRAMEWORK_PROP_RE.test(propertyName))
|
|
124
|
+
return true;
|
|
125
|
+
if (FRAMEWORK_SHAPE_NAME_RE.test(parentShape))
|
|
126
|
+
return true;
|
|
127
|
+
// Next.js incremental-cache / route context shape.
|
|
128
|
+
if (parentShapeProps.includes('defaultLocale') &&
|
|
129
|
+
parentShapeProps.includes('incrementalCache')) {
|
|
130
|
+
return true;
|
|
131
|
+
}
|
|
132
|
+
// A request-header bag: two or more header-ish props in the same shape.
|
|
133
|
+
// Deliberately heuristic, with two accepted trade-offs (kept conservative
|
|
134
|
+
// rather than tightened, since tuning needs real heap data):
|
|
135
|
+
// • False negative: `parentShapeProps` is the upstream sample, capped at 12
|
|
136
|
+
// entries and pre-sorted alphabetically, so an anonymous header bag whose
|
|
137
|
+
// 12 alphabetically-first props are not header-ish slips through. The
|
|
138
|
+
// common named shapes are still caught by FRAMEWORK_SHAPE_NAME_RE above.
|
|
139
|
+
// • False positive: an app object that genuinely models ≥2 of these
|
|
140
|
+
// HTTP-specific names (e.g. `cookie` + `user-agent`) is misclassified. The
|
|
141
|
+
// names are HTTP-specific enough that this is rare; `>= 2` (not `>= 1`)
|
|
142
|
+
// guards the most likely single-field collisions.
|
|
143
|
+
let headerish = 0;
|
|
144
|
+
for (const p of parentShapeProps) {
|
|
145
|
+
if (FRAMEWORK_SHAPE_PROPS.has(p))
|
|
146
|
+
headerish++;
|
|
147
|
+
}
|
|
148
|
+
return headerish >= 2;
|
|
149
|
+
}
|
|
150
|
+
export function registerInternOpportunities(server) {
|
|
151
|
+
server.tool('memlab_intern_opportunities', 'Identify string interning opportunities by grouping duplicated strings by the property name and parent object shape that holds them. Shows total savings per (property × shape) combination — the key metric for deciding where to add a string interning pool. Also surfaces ARRAY-ELEMENT / columnar duplication (strings held as elements of a rowsAsArray / string[][] result buffer — a common Nest mysql2/Drizzle shape) as first-class groups keyed by column index and array-owner shape, folded into the within-load headline; these are marked with a filled square and fixed by interning at the array-construction/parse site. The within-load figure is split at the canonical 128-char intern cap into cappable (<=128 chars, what the recommended fix reclaims) vs over-cap (longer strings the cap skips), so the headline matches what a compliant fix actually reclaims. Replaces the manual workflow of: duplicated_strings → retainer_summary → codebase grep. ' +
|
|
152
|
+
'Retention-aware: flags groups whose duplicated instances are ALSO held by another structure (e.g. a raw array/matrix) as "co-retained" — interning the property there reclaims ~0, so the savings are reported separately and you must dedupe the shared source instead. ' +
|
|
153
|
+
'The headline "within-load capturable" figure also excludes framework/infra-owned strings (HTTP headers, cookies, auth tokens, Next.js URL/cache context) and low-ROI groups (high-cardinality + long strings, where the intern pool costs more than it saves), reporting each in its own bucket; and it flags concatenated-string (rope) buildup, which is accumulation rather than duplication and cannot be interned. ' +
|
|
154
|
+
'⚠ Full-heap scan (builds string-duplication groups) — slow and memory-heavy on very large heaps (millions of nodes); raise min_copies / min_savings to bound it.', {
|
|
155
|
+
limit: z
|
|
156
|
+
.number()
|
|
157
|
+
.optional()
|
|
158
|
+
.default(15)
|
|
159
|
+
.describe('Maximum number of intern groups to return (default 15)'),
|
|
160
|
+
min_copies: z
|
|
161
|
+
.number()
|
|
162
|
+
.optional()
|
|
163
|
+
.default(100)
|
|
164
|
+
.describe('Minimum total copies across all strings in a group (default 100)'),
|
|
165
|
+
min_savings: z
|
|
166
|
+
.number()
|
|
167
|
+
.optional()
|
|
168
|
+
.default(102400)
|
|
169
|
+
.describe('Minimum savings in bytes to include a group (default 100 KB)'),
|
|
170
|
+
summary_only: z
|
|
171
|
+
.boolean()
|
|
172
|
+
.optional()
|
|
173
|
+
.default(false)
|
|
174
|
+
.describe('Triage mode: return only the headline savings split (within-load / co-retained / cross-load), a one-line verdict, and the ranked group table — dropping the per-group top-strings, the "How to fix" block, and "Next steps". Ideal for screening many snapshots without flooding context.'),
|
|
175
|
+
}, async ({ limit, min_copies, min_savings, summary_only }) => {
|
|
176
|
+
try {
|
|
177
|
+
const snapshot = getSnapshot();
|
|
178
|
+
const meta = getSnapshotMetadata();
|
|
179
|
+
const totalSize = meta?.totalSize ?? 0;
|
|
180
|
+
// Step 1: Build frequency map of duplicated strings
|
|
181
|
+
const stringMap = new Map();
|
|
182
|
+
// Cheap retention-pattern signal (Feedback round 7 §4): a heap dominated
|
|
183
|
+
// by `(concatenated string)` (cons/rope) nodes is string ACCUMULATION
|
|
184
|
+
// (repeated `+=` / join into a long-lived buffer), NOT value duplication
|
|
185
|
+
// — interning cannot help. Count them by name in the pass we already do
|
|
186
|
+
// (O(1) per node, no value materialization) so we can flag the pattern
|
|
187
|
+
// instead of silently reporting tiny interning wins.
|
|
188
|
+
let concatStringCount = 0;
|
|
189
|
+
let concatStringSize = 0;
|
|
190
|
+
snapshot.nodes.forEach(node => {
|
|
191
|
+
if (node.name === '(concatenated string)') {
|
|
192
|
+
concatStringCount++;
|
|
193
|
+
concatStringSize += node.self_size;
|
|
194
|
+
}
|
|
195
|
+
if (node.type !== 'string')
|
|
196
|
+
return;
|
|
197
|
+
if (node.name === 'system / SlicedString')
|
|
198
|
+
return;
|
|
199
|
+
const strNode = node.toStringNode();
|
|
200
|
+
if (!strNode)
|
|
201
|
+
return;
|
|
202
|
+
const value = strNode.stringValue;
|
|
203
|
+
const entry = stringMap.get(value);
|
|
204
|
+
if (entry) {
|
|
205
|
+
entry.count++;
|
|
206
|
+
entry.totalSize += node.retainedSize;
|
|
207
|
+
if (entry.exampleIds.length < 20) {
|
|
208
|
+
entry.exampleIds.push(node.id);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
else {
|
|
212
|
+
stringMap.set(value, {
|
|
213
|
+
count: 1,
|
|
214
|
+
totalSize: node.retainedSize,
|
|
215
|
+
exampleIds: [node.id],
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
// Step 2: For duplicated strings, sample referrers to get property × shape
|
|
220
|
+
const groupMap = new Map();
|
|
221
|
+
for (const [value, stats] of stringMap) {
|
|
222
|
+
if (stats.count < 2)
|
|
223
|
+
continue;
|
|
224
|
+
// Sample referrers to determine which (property, shape) groups hold this string
|
|
225
|
+
const groupDist = new Map();
|
|
226
|
+
let samplesProcessed = 0;
|
|
227
|
+
for (const nodeId of stats.exampleIds) {
|
|
228
|
+
const node = snapshot.getNodeById(nodeId);
|
|
229
|
+
if (!node)
|
|
230
|
+
continue;
|
|
231
|
+
// Gather a bounded set of this instance's referrers once, so we can
|
|
232
|
+
// both (a) pick the primary property/context referrer for grouping
|
|
233
|
+
// and (b) detect co-retention: if the exact same string instance is
|
|
234
|
+
// also held by an INDEPENDENT structure (e.g. a raw array/matrix
|
|
235
|
+
// cell on a different object), interning at the property assignment
|
|
236
|
+
// site frees ~nothing because the other referrer keeps the per-row
|
|
237
|
+
// instance alive. This is the difference between a fix that reclaims
|
|
238
|
+
// the bytes and one that reclaims ~0 (Feedback round 4 §1:
|
|
239
|
+
// retention-aware savings).
|
|
240
|
+
// Collect up to 8 referrers via the streaming iterator, NOT the
|
|
241
|
+
// `node.referrers` getter: that getter materializes a JS array of
|
|
242
|
+
// ALL incoming edges on every access, so a string referenced N times
|
|
243
|
+
// (common low-cardinality values are referenced 1000s of times) pays
|
|
244
|
+
// O(N) per sample. forEachReferrer stops after 8 without building the
|
|
245
|
+
// full array.
|
|
246
|
+
const refs = [];
|
|
247
|
+
node.forEachReferrer(ref => {
|
|
248
|
+
refs.push(ref);
|
|
249
|
+
if (refs.length >= 8)
|
|
250
|
+
return { stop: true };
|
|
251
|
+
});
|
|
252
|
+
// Primary referrer = first property/context edge (assignment site).
|
|
253
|
+
// If there is none, fall back to an array-element referrer so strings
|
|
254
|
+
// held as ARRAY ELEMENTS (columnar / rows-as-arrays — a very common
|
|
255
|
+
// Nest shape: mysql2/Drizzle rowsAsArray SELECT buffers) become a
|
|
256
|
+
// first-class group instead of being silently dropped. Previously the
|
|
257
|
+
// biggest single win of a sweep could hide because it was columnar
|
|
258
|
+
// (sweep feedback §2).
|
|
259
|
+
let primary = null;
|
|
260
|
+
for (const ref of refs) {
|
|
261
|
+
if (ref.type === 'property' || ref.type === 'context') {
|
|
262
|
+
primary = ref;
|
|
263
|
+
break;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
let isArrayElement = false;
|
|
267
|
+
if (!primary) {
|
|
268
|
+
for (const ref of refs) {
|
|
269
|
+
if (ref.type === 'element') {
|
|
270
|
+
primary = ref;
|
|
271
|
+
isArrayElement = true;
|
|
272
|
+
break;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
if (!primary)
|
|
277
|
+
continue;
|
|
278
|
+
const parent = primary.fromNode;
|
|
279
|
+
// For array elements, key the "property" by the column index so a
|
|
280
|
+
// fixed column across rows groups together (e.g. `[3]`); named props
|
|
281
|
+
// key by their name as before.
|
|
282
|
+
const propName = isArrayElement
|
|
283
|
+
? `[${String(primary.name_or_index)}]`
|
|
284
|
+
: String(primary.name_or_index);
|
|
285
|
+
// Co-retained ONLY if a referrer comes from a genuinely independent
|
|
286
|
+
// structure. A referrer that is the same object's own V8 backing
|
|
287
|
+
// store (PropertyArray / object-elements / sliced-string, or any
|
|
288
|
+
// hidden/internal edge) still collapses under interning, so it must
|
|
289
|
+
// NOT be counted as co-retention — counting it was discounting the
|
|
290
|
+
// biggest real wins by ~2× (Feedback round 5 §1/§2).
|
|
291
|
+
let coRetainedVia;
|
|
292
|
+
for (const ref of refs) {
|
|
293
|
+
if (ref.fromNode.id === parent.id)
|
|
294
|
+
continue;
|
|
295
|
+
if (isOwnStorageReferrer(ref))
|
|
296
|
+
continue;
|
|
297
|
+
const fromName = ref.fromNode.name || 'Object';
|
|
298
|
+
if (ref.type === 'element') {
|
|
299
|
+
coRetainedVia =
|
|
300
|
+
(fromName === 'Object' || fromName === ''
|
|
301
|
+
? 'Array'
|
|
302
|
+
: fromName) + '[] (array element)';
|
|
303
|
+
}
|
|
304
|
+
else if (ref.type === 'property' || ref.type === 'context') {
|
|
305
|
+
coRetainedVia = `${fromName}.${String(ref.name_or_index)}`;
|
|
306
|
+
}
|
|
307
|
+
else {
|
|
308
|
+
coRetainedVia = `${fromName} (${ref.type})`;
|
|
309
|
+
}
|
|
310
|
+
break;
|
|
311
|
+
}
|
|
312
|
+
// Fingerprint the parent shape from up to 12 property names.
|
|
313
|
+
// CRITICAL: never touch `parent.references` — that getter
|
|
314
|
+
// materializes a JS array of ALL outgoing edges on every access, so
|
|
315
|
+
// on a giant parent (a map/config object, or one with a huge backing
|
|
316
|
+
// store) it is O(edge_count) PER SAMPLE PER duplicated string and
|
|
317
|
+
// wedges the tool (observed: avg 185k, max 504k edges, ~390k of
|
|
318
|
+
// ~978k samples on one real heap). `edge_count` is O(1): skip shape
|
|
319
|
+
// detection for oversized parents (they are not the row-shaped
|
|
320
|
+
// objects we intern — grouping them by node name is enough), and for
|
|
321
|
+
// the rest use the non-materializing `forEachReference` iterator with
|
|
322
|
+
// an absolute visit cap as a backstop.
|
|
323
|
+
const parentProps = [];
|
|
324
|
+
let shapeKey;
|
|
325
|
+
if (isArrayElement) {
|
|
326
|
+
// Arrays carry element edges, not a property shape — label by the
|
|
327
|
+
// array's owner (one level up) so columnar rows group by owner.
|
|
328
|
+
shapeKey = arrayOwnerLabel(parent);
|
|
329
|
+
}
|
|
330
|
+
else {
|
|
331
|
+
const PARENT_EDGE_GUARD = 1024;
|
|
332
|
+
const PARENT_SCAN_CAP = 256;
|
|
333
|
+
if (parent.edge_count <= PARENT_EDGE_GUARD) {
|
|
334
|
+
let scanned = 0;
|
|
335
|
+
parent.forEachReference(edge => {
|
|
336
|
+
if (++scanned > PARENT_SCAN_CAP)
|
|
337
|
+
return { stop: true };
|
|
338
|
+
if (edge.type === 'property') {
|
|
339
|
+
parentProps.push(String(edge.name_or_index));
|
|
340
|
+
if (parentProps.length >= 12)
|
|
341
|
+
return { stop: true };
|
|
342
|
+
}
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
parentProps.sort();
|
|
346
|
+
shapeKey =
|
|
347
|
+
parent.name !== 'Object'
|
|
348
|
+
? parent.name
|
|
349
|
+
: parentProps.length > 0
|
|
350
|
+
? `{${parentProps.join(',')}}`
|
|
351
|
+
: 'Object';
|
|
352
|
+
}
|
|
353
|
+
const groupKey = `${propName}::${shapeKey}`;
|
|
354
|
+
const dist = groupDist.get(groupKey);
|
|
355
|
+
if (dist) {
|
|
356
|
+
dist.sampledCount++;
|
|
357
|
+
dist.sampleRetained += node.retainedSize;
|
|
358
|
+
if (coRetainedVia) {
|
|
359
|
+
dist.coRetainedCount++;
|
|
360
|
+
if (!dist.coRetainedVia)
|
|
361
|
+
dist.coRetainedVia = coRetainedVia;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
else {
|
|
365
|
+
groupDist.set(groupKey, {
|
|
366
|
+
sampledCount: 1,
|
|
367
|
+
propName,
|
|
368
|
+
parentProps,
|
|
369
|
+
shapeKey,
|
|
370
|
+
parentId: parent.id,
|
|
371
|
+
sampleRetained: node.retainedSize,
|
|
372
|
+
coRetainedCount: coRetainedVia ? 1 : 0,
|
|
373
|
+
coRetainedVia,
|
|
374
|
+
arrayElement: isArrayElement,
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
samplesProcessed++;
|
|
378
|
+
}
|
|
379
|
+
if (samplesProcessed === 0)
|
|
380
|
+
continue;
|
|
381
|
+
// Extrapolate true counts from sample using stats.count
|
|
382
|
+
for (const [groupKey, dist] of groupDist) {
|
|
383
|
+
const scaleFactor = stats.count / samplesProcessed;
|
|
384
|
+
const trueCount = Math.round(dist.sampledCount * scaleFactor);
|
|
385
|
+
const trueSize = stats.totalSize * (dist.sampledCount / samplesProcessed);
|
|
386
|
+
const existing = groupMap.get(groupKey);
|
|
387
|
+
if (existing) {
|
|
388
|
+
const strEntry = existing.strings.get(value);
|
|
389
|
+
if (strEntry) {
|
|
390
|
+
strEntry.count += trueCount;
|
|
391
|
+
strEntry.size += trueSize;
|
|
392
|
+
}
|
|
393
|
+
else {
|
|
394
|
+
existing.strings.set(value, {
|
|
395
|
+
count: trueCount,
|
|
396
|
+
size: trueSize,
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
existing.totalCopies += trueCount;
|
|
400
|
+
existing.totalSize += trueSize;
|
|
401
|
+
existing.groupSamples += dist.sampledCount;
|
|
402
|
+
existing.coRetainedSamples += dist.coRetainedCount;
|
|
403
|
+
if (!existing.coRetainedVia && dist.coRetainedVia) {
|
|
404
|
+
existing.coRetainedVia = dist.coRetainedVia;
|
|
405
|
+
}
|
|
406
|
+
// OR the flag across samples rather than trusting the first: if a
|
|
407
|
+
// key is ever reached from both an element and a non-element
|
|
408
|
+
// referrer (e.g. a property literally named `[N]`), the group is
|
|
409
|
+
// still treated as columnar so the label/marker/totals stay
|
|
410
|
+
// consistent.
|
|
411
|
+
existing.arrayElement =
|
|
412
|
+
existing.arrayElement || dist.arrayElement;
|
|
413
|
+
}
|
|
414
|
+
else {
|
|
415
|
+
groupMap.set(groupKey, {
|
|
416
|
+
propertyName: dist.propName,
|
|
417
|
+
parentShapeProps: dist.parentProps,
|
|
418
|
+
parentShapeKey: dist.shapeKey,
|
|
419
|
+
strings: new Map([[value, { count: trueCount, size: trueSize }]]),
|
|
420
|
+
totalCopies: trueCount,
|
|
421
|
+
totalSize: trueSize,
|
|
422
|
+
exampleParentId: dist.parentId,
|
|
423
|
+
groupSamples: dist.sampledCount,
|
|
424
|
+
coRetainedSamples: dist.coRetainedCount,
|
|
425
|
+
coRetainedVia: dist.coRetainedVia,
|
|
426
|
+
arrayElement: dist.arrayElement,
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
// Step 3: Compute savings and filter
|
|
432
|
+
const groups = [];
|
|
433
|
+
for (const g of groupMap.values()) {
|
|
434
|
+
if (g.totalCopies < min_copies)
|
|
435
|
+
continue;
|
|
436
|
+
let savingsIfInterned = 0;
|
|
437
|
+
let savingsCappable = 0;
|
|
438
|
+
let savingsOverCap = 0;
|
|
439
|
+
const topStrings = [];
|
|
440
|
+
for (const [value, strStats] of g.strings) {
|
|
441
|
+
if (strStats.count > 1) {
|
|
442
|
+
const perCopy = strStats.size / strStats.count;
|
|
443
|
+
const s = (strStats.count - 1) * perCopy;
|
|
444
|
+
savingsIfInterned += s;
|
|
445
|
+
// A ≤128-char value is what the canonical intern fix would cap and
|
|
446
|
+
// reclaim; longer values are skipped by that cap (feedback §3).
|
|
447
|
+
if (value.length <= CANONICAL_CAP_CHARS)
|
|
448
|
+
savingsCappable += s;
|
|
449
|
+
else
|
|
450
|
+
savingsOverCap += s;
|
|
451
|
+
}
|
|
452
|
+
topStrings.push({
|
|
453
|
+
value,
|
|
454
|
+
count: strStats.count,
|
|
455
|
+
size: strStats.size,
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
if (savingsIfInterned < min_savings)
|
|
459
|
+
continue;
|
|
460
|
+
topStrings.sort((a, b) => b.size - a.size);
|
|
461
|
+
// Co-retained when the majority of sampled instances are also held by
|
|
462
|
+
// another structure — interning the property alone reclaims ~0.
|
|
463
|
+
const coRetained = g.coRetainedSamples > 0 &&
|
|
464
|
+
g.coRetainedSamples >= g.groupSamples * 0.5;
|
|
465
|
+
groups.push({
|
|
466
|
+
propertyName: g.propertyName,
|
|
467
|
+
parentShape: g.parentShapeKey,
|
|
468
|
+
parentShapeProps: g.parentShapeProps,
|
|
469
|
+
uniqueStrings: g.strings.size,
|
|
470
|
+
totalCopies: g.totalCopies,
|
|
471
|
+
totalSize: g.totalSize,
|
|
472
|
+
savingsIfInterned,
|
|
473
|
+
savingsCappable,
|
|
474
|
+
savingsOverCap,
|
|
475
|
+
topStrings: topStrings.slice(0, 3),
|
|
476
|
+
exampleParentId: g.exampleParentId,
|
|
477
|
+
arrayElement: g.arrayElement,
|
|
478
|
+
coRetained,
|
|
479
|
+
coRetainedVia: coRetained ? g.coRetainedVia : undefined,
|
|
480
|
+
lowRoi: isLowRoiGroup(g.strings.size, g.totalCopies, g.totalSize),
|
|
481
|
+
frameworkOwned: isFrameworkOwned(g.propertyName, g.parentShapeKey, g.parentShapeProps),
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
groups.sort((a, b) => b.savingsIfInterned - a.savingsIfInterned);
|
|
485
|
+
const shown = groups.slice(0, limit);
|
|
486
|
+
if (shown.length === 0) {
|
|
487
|
+
// The property×shape grouping above only sees strings held as named
|
|
488
|
+
// OBJECT PROPERTIES. Strings held as ARRAY ELEMENTS (columnar /
|
|
489
|
+
// rows-as-arrays — e.g. a DB driver's string[][] result buffer) never
|
|
490
|
+
// form a property group, so a heap can show millions of duplicated
|
|
491
|
+
// cells yet report zero opportunities here. Cross-check the already-
|
|
492
|
+
// built stringMap (in-memory Map iteration — no extra heap traversal)
|
|
493
|
+
// and surface the heaviest duplicates so the user isn't dead-ended.
|
|
494
|
+
const arrayDupes = [];
|
|
495
|
+
for (const [value, s] of stringMap) {
|
|
496
|
+
if (s.count < min_copies)
|
|
497
|
+
continue;
|
|
498
|
+
const savings = (s.totalSize * (s.count - 1)) / s.count;
|
|
499
|
+
if (savings < min_savings)
|
|
500
|
+
continue;
|
|
501
|
+
arrayDupes.push({ value, count: s.count, savings });
|
|
502
|
+
}
|
|
503
|
+
arrayDupes.sort((a, b) => b.savings - a.savings);
|
|
504
|
+
let msg = `No significant interning opportunities found (min ${formatNumber(min_copies)} copies, min ${formatBytes(min_savings)} savings). Try lowering thresholds. If the heap is instead dominated by a few large strings/objects (not many small duplicates), interning won't help — use memlab_largest_objects or memlab_sliced_strings to investigate blob retention.`;
|
|
505
|
+
if (concatStringCount > CONCAT_STRING_BUILDUP_FLOOR) {
|
|
506
|
+
msg += `\n\n⚠ Concatenated-string buildup: ${formatNumber(concatStringCount)} \`(concatenated string)\` nodes (~${formatBytes(concatStringSize)} self-size). This is string ACCUMULATION (repeated \`+=\` / join into a long-lived buffer), NOT value duplication — interning cannot help. Investigate with memlab_largest_objects / memlab_sliced_strings and trace the retaining structure.`;
|
|
507
|
+
}
|
|
508
|
+
if (arrayDupes.length > 0) {
|
|
509
|
+
const totalDup = arrayDupes.reduce((a, d) => a + d.savings, 0);
|
|
510
|
+
const top = arrayDupes
|
|
511
|
+
.slice(0, 5)
|
|
512
|
+
.map(d => ` • ${JSON.stringify(d.value.length > 40 ? d.value.slice(0, 40) + '…' : d.value)} ×${formatNumber(d.count)} (~${formatBytes(d.savings)})`)
|
|
513
|
+
.join('\n');
|
|
514
|
+
msg += `\n\n⚠ However, ${formatNumber(arrayDupes.length)} value(s) are heavily duplicated (~${formatBytes(totalDup)} total) but were NOT surfaced as property groups above. This is expected when they are held as ARRAY ELEMENTS / columnar rows (e.g. a string[][] query-result buffer) rather than object properties — but it can also happen when their property groups fell below the min_copies / min_savings thresholds. If they are array elements, interning at the array-construction (parse) site collapses them; if they are properties, lower the thresholds to surface the group. Top:\n${top}\nUse memlab_search_strings to locate where each is built and confirm how it is held.`;
|
|
515
|
+
}
|
|
516
|
+
return toolResult(msg);
|
|
517
|
+
}
|
|
518
|
+
// Detect partial interning patterns (Feedback #3) and, while we're here,
|
|
519
|
+
// mark which groups' savings are CROSS-load (not capturable by a per-load
|
|
520
|
+
// intern pool) so the headline can split the two (Feedback round 3 §1c).
|
|
521
|
+
const partialInternAlerts = [];
|
|
522
|
+
for (const g of shown) {
|
|
523
|
+
if (g.uniqueStrings < 10)
|
|
524
|
+
continue;
|
|
525
|
+
const counts = g.topStrings.map(s => s.count);
|
|
526
|
+
if (counts.length < 2)
|
|
527
|
+
continue;
|
|
528
|
+
const median = counts.sort((a, b) => a - b)[Math.floor(counts.length / 2)];
|
|
529
|
+
if (median < 2 || median > 20)
|
|
530
|
+
continue;
|
|
531
|
+
const consistent = counts.every(c => c >= median * 0.5 && c <= median * 2);
|
|
532
|
+
if (!consistent)
|
|
533
|
+
continue;
|
|
534
|
+
g.crossLoad = true;
|
|
535
|
+
const perPoolSavings = g.savingsIfInterned;
|
|
536
|
+
partialInternAlerts.push(`⚠️ **\`.${g.propertyName}\` on \`${g.parentShape.length > 50 ? g.parentShape.slice(0, 47) + '…}' : g.parentShape}\`:** ` +
|
|
537
|
+
`${formatNumber(g.uniqueStrings)} unique strings each duplicated ~${median}× — ` +
|
|
538
|
+
`suggests **${median} independent intern pools** instead of one shared pool. ` +
|
|
539
|
+
`Consolidating into a single shared pool would save ~${formatBytes(perPoolSavings)}.`);
|
|
540
|
+
}
|
|
541
|
+
// Split savings into mutually-exclusive buckets (Feedback round 4 §1 —
|
|
542
|
+
// retention-aware — extended in round 7):
|
|
543
|
+
// • co-retained — the duplicated instances are ALSO held by another
|
|
544
|
+
// structure, so interning the property reclaims ~0; the shared
|
|
545
|
+
// source must be deduped/dropped. Reported separately so the figure
|
|
546
|
+
// is not mistaken for an easy per-property win.
|
|
547
|
+
// • within-load — capturable by a per-load/per-request intern pool.
|
|
548
|
+
// • cross-load — needs a shared/module-scope pool (Feedback round 3 §1c).
|
|
549
|
+
// • framework — header/cookie/token/Next.js-context strings; not app
|
|
550
|
+
// data, so an app-level intern pool should not target them
|
|
551
|
+
// (Feedback round 7 §2).
|
|
552
|
+
// • low-ROI — high-cardinality + long strings whose pool cost dwarfs the
|
|
553
|
+
// collapse; usually skip (Feedback round 7 §1).
|
|
554
|
+
// Each group lands in exactly one bucket (precedence below) so the
|
|
555
|
+
// headline within-load figure reflects only realistic app-data wins.
|
|
556
|
+
// Framework/infra-owned is checked FIRST: "this is not application data"
|
|
557
|
+
// is the most fundamental classification and the most actionable label
|
|
558
|
+
// for the reader (skip it — it isn't yours), so a framework string is
|
|
559
|
+
// always surfaced as framework even when it is ALSO co-retained or
|
|
560
|
+
// duplicated across loads — HTTP request headers are duplicated across
|
|
561
|
+
// requests by nature, so a header would otherwise be miscounted as
|
|
562
|
+
// cross-load and the framework bucket under-counted. The remaining order
|
|
563
|
+
// is retention-then-ROI; every non-`within` bucket is excluded from the
|
|
564
|
+
// within-load headline regardless of which one a group lands in.
|
|
565
|
+
const bucketOf = (g) => {
|
|
566
|
+
if (g.frameworkOwned)
|
|
567
|
+
return 'framework';
|
|
568
|
+
if (g.coRetained)
|
|
569
|
+
return 'coRetained';
|
|
570
|
+
if (g.crossLoad)
|
|
571
|
+
return 'crossLoad';
|
|
572
|
+
if (g.lowRoi)
|
|
573
|
+
return 'lowRoi';
|
|
574
|
+
return 'within';
|
|
575
|
+
};
|
|
576
|
+
const sumBucket = (bucket) => shown
|
|
577
|
+
.filter(g => bucketOf(g) === bucket)
|
|
578
|
+
.reduce((sum, g) => sum + g.savingsIfInterned, 0);
|
|
579
|
+
const coRetainedSavings = sumBucket('coRetained');
|
|
580
|
+
const crossLoadSavings = sumBucket('crossLoad');
|
|
581
|
+
const frameworkSavings = sumBucket('framework');
|
|
582
|
+
const lowRoiSavings = sumBucket('lowRoi');
|
|
583
|
+
const withinLoadSavings = sumBucket('within');
|
|
584
|
+
// Within the capturable bucket, split by the canonical 128-char cap so the
|
|
585
|
+
// headline reports what a COMPLIANT fix reclaims, not the raw total that
|
|
586
|
+
// includes over-cap strings the fix skips (feedback §3). Also surface how
|
|
587
|
+
// much of the capturable win is columnar / array-element duplication so it
|
|
588
|
+
// is no longer buried (feedback §2).
|
|
589
|
+
const withinGroups = shown.filter(g => bucketOf(g) === 'within');
|
|
590
|
+
const withinCappable = withinGroups.reduce((s, g) => s + g.savingsCappable, 0);
|
|
591
|
+
const withinOverCap = withinGroups.reduce((s, g) => s + g.savingsOverCap, 0);
|
|
592
|
+
// Use the cappable portion (not the full savings) so this figure always
|
|
593
|
+
// fits inside the withinCappable headline — otherwise a columnar group
|
|
594
|
+
// with long strings could report "includes N of columnar" where N
|
|
595
|
+
// exceeds the leading cappable number, which reads as a contradiction.
|
|
596
|
+
const withinArrayElementCappable = withinGroups
|
|
597
|
+
.filter(g => g.arrayElement)
|
|
598
|
+
.reduce((s, g) => s + g.savingsCappable, 0);
|
|
599
|
+
const totalSavings = withinLoadSavings +
|
|
600
|
+
crossLoadSavings +
|
|
601
|
+
coRetainedSavings +
|
|
602
|
+
frameworkSavings +
|
|
603
|
+
lowRoiSavings;
|
|
604
|
+
const pctOf = (n) => totalSize > 0
|
|
605
|
+
? ` (${((n / totalSize) * 100).toFixed(1)}% of heap)`
|
|
606
|
+
: '';
|
|
607
|
+
const pctOfHeap = pctOf(totalSavings);
|
|
608
|
+
// Duplication factor (copies ÷ unique) per group — the single best
|
|
609
|
+
// "is this cross-load?" signal: a value held ~N× by N same-shape objects
|
|
610
|
+
// each from a different call/load won't collapse under a per-request pool
|
|
611
|
+
// (Feedback round 5 §3). Reported as a column so the agent can eyeball it.
|
|
612
|
+
const fmtDup = (copies, unique) => {
|
|
613
|
+
if (unique <= 0)
|
|
614
|
+
return '-';
|
|
615
|
+
const f = copies / unique;
|
|
616
|
+
return f >= 10 ? `${Math.round(f)}×` : `${f.toFixed(1)}×`;
|
|
617
|
+
};
|
|
618
|
+
const headers = [
|
|
619
|
+
'Property',
|
|
620
|
+
'Parent Shape',
|
|
621
|
+
'Unique Strings',
|
|
622
|
+
'Total Copies',
|
|
623
|
+
'Dup ×',
|
|
624
|
+
'Avg len',
|
|
625
|
+
'Total Size',
|
|
626
|
+
'Savings',
|
|
627
|
+
'% Heap',
|
|
628
|
+
'Example Parent',
|
|
629
|
+
];
|
|
630
|
+
const rightCols = new Set([2, 3, 4, 5, 6, 7, 8]);
|
|
631
|
+
const rows = shown.map(g => {
|
|
632
|
+
const shape = g.parentShape.length > 40
|
|
633
|
+
? g.parentShape.slice(0, 37) + '…}'
|
|
634
|
+
: g.parentShape;
|
|
635
|
+
const pct = totalSize > 0
|
|
636
|
+
? ((g.savingsIfInterned / totalSize) * 100).toFixed(1) + '%'
|
|
637
|
+
: '-';
|
|
638
|
+
const avgLen = g.totalCopies > 0
|
|
639
|
+
? formatBytes(Math.round(g.totalSize / g.totalCopies))
|
|
640
|
+
: '-';
|
|
641
|
+
// Array-element groups already read as `[3]`; a leading dot would
|
|
642
|
+
// produce a malformed label, so only prefix `.` for named properties.
|
|
643
|
+
const label = g.arrayElement ? g.propertyName : `.${g.propertyName}`;
|
|
644
|
+
return [
|
|
645
|
+
`${label}${g.arrayElement ? ' ▦' : ''}${g.coRetained ? ' ⚠' : ''}${g.crossLoad ? ' ⤫' : ''}${g.frameworkOwned ? ' ▤' : ''}${g.lowRoi ? ' ▽' : ''}`,
|
|
646
|
+
shape,
|
|
647
|
+
formatNumber(g.uniqueStrings),
|
|
648
|
+
formatNumber(g.totalCopies),
|
|
649
|
+
fmtDup(g.totalCopies, g.uniqueStrings),
|
|
650
|
+
avgLen,
|
|
651
|
+
formatBytes(g.totalSize),
|
|
652
|
+
formatBytes(g.savingsIfInterned),
|
|
653
|
+
pct,
|
|
654
|
+
`@${g.exampleParentId}`,
|
|
655
|
+
];
|
|
656
|
+
});
|
|
657
|
+
// Retention/concurrency-bug signature (Feedback round 6 §4): a heap
|
|
658
|
+
// dominated by CROSS-load groups whose copies ÷ unique ≈ 2.0 is two full
|
|
659
|
+
// copies of the same dataset resident at once (a stale+fresh
|
|
660
|
+
// double-buffer / setInterval retention), NOT a value duplicated within a
|
|
661
|
+
// single parse. A per-request intern pool cannot collapse it — the fix is
|
|
662
|
+
// to stop the double retention at the source. Detect it purely from the
|
|
663
|
+
// already-computed group counts (no extra heap traversal) so the verdict
|
|
664
|
+
// can call it out explicitly instead of leaving the agent to infer it.
|
|
665
|
+
const isExactlyTwoX = (g) => {
|
|
666
|
+
if (g.uniqueStrings <= 0)
|
|
667
|
+
return false;
|
|
668
|
+
const f = g.totalCopies / g.uniqueStrings;
|
|
669
|
+
return f >= 1.8 && f <= 2.2;
|
|
670
|
+
};
|
|
671
|
+
// Measure cross-load duplication from the raw `crossLoad` flag, NOT the
|
|
672
|
+
// post-precedence bucket. Framework-owned strings are reclassified out of
|
|
673
|
+
// the `crossLoad` bucket, but a stale+fresh double-buffer of e.g. HTTP
|
|
674
|
+
// headers is a real retention/concurrency bug regardless of who owns the
|
|
675
|
+
// data — and the verdict for it is explicitly "NOT interning". Gating on
|
|
676
|
+
// the bucket sum would let the detector silently stop firing once 2×
|
|
677
|
+
// framework groups leave the `crossLoad` bucket.
|
|
678
|
+
const crossLoadDupSavings = shown
|
|
679
|
+
.filter(g => g.crossLoad && !g.coRetained)
|
|
680
|
+
.reduce((sum, g) => sum + g.savingsIfInterned, 0);
|
|
681
|
+
const twoXCrossLoadSavings = shown
|
|
682
|
+
.filter(g => g.crossLoad && !g.coRetained && isExactlyTwoX(g))
|
|
683
|
+
.reduce((sum, g) => sum + g.savingsIfInterned, 0);
|
|
684
|
+
const retentionBugSuspected = crossLoadDupSavings > 0 &&
|
|
685
|
+
crossLoadDupSavings >= withinLoadSavings &&
|
|
686
|
+
crossLoadDupSavings >= coRetainedSavings &&
|
|
687
|
+
twoXCrossLoadSavings >= crossLoadDupSavings * 0.5;
|
|
688
|
+
// One-line verdict (Feedback round 5 §9): which bucket dominates decides
|
|
689
|
+
// the fix shape, so lead with it before the detail. A suspected
|
|
690
|
+
// retention/concurrency bug is checked FIRST — it is a real memory bug
|
|
691
|
+
// (NOT an interning win) and is framework-independent, so it must surface
|
|
692
|
+
// even when the 2× duplication is framework-owned and bucketed out of
|
|
693
|
+
// `crossLoad`.
|
|
694
|
+
// Every "mostly <bucket>" branch gates on the bucket being ≥ ALL other
|
|
695
|
+
// buckets (including framework/low-ROI), so the headline always names the
|
|
696
|
+
// bucket that actually dominates the heap. Without the framework/low-ROI
|
|
697
|
+
// comparison the cascade is asymmetric: a second-largest co-retained or
|
|
698
|
+
// cross-load bucket would be reported as the headline even when
|
|
699
|
+
// framework/low-ROI dominate (e.g. framework=100MB, crossLoad=10MB). The
|
|
700
|
+
// retention-bug branch is exempt — it is a correctness flag, not a
|
|
701
|
+
// "biggest bucket" claim, so it leads regardless of magnitude.
|
|
702
|
+
let verdict;
|
|
703
|
+
if (retentionBugSuspected) {
|
|
704
|
+
verdict = `Verdict: ⚠ likely **retention/concurrency bug** (NOT interning) — ${formatBytes(twoXCrossLoadSavings)} of cross-load duplication at ~2.0× (copies ÷ unique), i.e. two copies of the same dataset held at once (stale+fresh double-buffer / setInterval). A per-request intern pool will NOT help; fix the double retention at the source.`;
|
|
705
|
+
}
|
|
706
|
+
else if (coRetainedSavings >= withinLoadSavings &&
|
|
707
|
+
coRetainedSavings >= crossLoadSavings &&
|
|
708
|
+
coRetainedSavings >= frameworkSavings &&
|
|
709
|
+
coRetainedSavings >= lowRoiSavings &&
|
|
710
|
+
coRetainedSavings > 0) {
|
|
711
|
+
verdict = `Verdict: mostly **co-retained** (${formatBytes(coRetainedSavings)}) — interning the property won't help; dedupe at the shared source.`;
|
|
712
|
+
}
|
|
713
|
+
else if (crossLoadSavings >= withinLoadSavings &&
|
|
714
|
+
crossLoadSavings >= frameworkSavings &&
|
|
715
|
+
crossLoadSavings >= lowRoiSavings &&
|
|
716
|
+
crossLoadSavings > 0) {
|
|
717
|
+
verdict = `Verdict: mostly **cross-load** (${formatBytes(crossLoadSavings)}) — a per-request pool won't collapse it; needs a shared/module-scope pool or a retention fix.`;
|
|
718
|
+
}
|
|
719
|
+
else if (withinLoadSavings > 0 &&
|
|
720
|
+
withinLoadSavings >= frameworkSavings &&
|
|
721
|
+
withinLoadSavings >= lowRoiSavings) {
|
|
722
|
+
// Lead with the ≤128-char cappable figure — the amount the canonical
|
|
723
|
+
// intern fix actually reclaims — not the raw within-load total that also
|
|
724
|
+
// counts over-cap strings the fix skips (feedback §3).
|
|
725
|
+
const capNote = withinOverCap > 0
|
|
726
|
+
? ` (+${formatBytes(withinOverCap)} in >${CANONICAL_CAP_CHARS}-char strings the 128-char cap skips — needs an uncapped pool or a different fix)`
|
|
727
|
+
: '';
|
|
728
|
+
const columnarNote = withinArrayElementCappable > 0
|
|
729
|
+
? ` Includes ${formatBytes(withinArrayElementCappable)} of columnar / array-element (rowsAsArray) duplication — intern at the array-construction/parse site.`
|
|
730
|
+
: '';
|
|
731
|
+
verdict = `Verdict: **${formatBytes(withinCappable)} cappable** by the canonical ≤${CANONICAL_CAP_CHARS}-char per-load/per-request intern pool at the parse boundary${capNote}.${columnarNote}`;
|
|
732
|
+
}
|
|
733
|
+
else if (frameworkSavings + lowRoiSavings > 0) {
|
|
734
|
+
// Framework/low-ROI dominate (each ≥ the within-load figure). Lead with
|
|
735
|
+
// them so a small capturable remainder isn't mistaken for the headline,
|
|
736
|
+
// but still name that remainder when nonzero so it isn't hidden.
|
|
737
|
+
const parts = [];
|
|
738
|
+
if (frameworkSavings > 0) {
|
|
739
|
+
parts.push('framework/infra-owned (headers/cookies/tokens)');
|
|
740
|
+
}
|
|
741
|
+
if (lowRoiSavings > 0) {
|
|
742
|
+
parts.push('low-ROI (high-cardinality, long strings)');
|
|
743
|
+
}
|
|
744
|
+
const remainder = withinLoadSavings > 0
|
|
745
|
+
? ` (only ${formatBytes(withinLoadSavings)} is app-capturable by a per-load pool)`
|
|
746
|
+
: '';
|
|
747
|
+
verdict = `Verdict: largely no app-actionable interning${remainder} — the bulk is ${parts.join(' and ')}, which a per-load app intern pool should not target.`;
|
|
748
|
+
}
|
|
749
|
+
else {
|
|
750
|
+
verdict =
|
|
751
|
+
'Verdict: no clearly-capturable interning savings — if the heap is dominated by a few large strings/objects, use memlab_largest_objects or memlab_sliced_strings to investigate blob retention.';
|
|
752
|
+
}
|
|
753
|
+
const headerLines = [
|
|
754
|
+
`# String Interning Opportunities`,
|
|
755
|
+
'',
|
|
756
|
+
verdict,
|
|
757
|
+
'',
|
|
758
|
+
`**Total duplication across top ${shown.length} groups: ${formatBytes(totalSavings)}${pctOfHeap}**`,
|
|
759
|
+
`- **Within-load (capturable by a per-load/per-request intern pool): ${formatBytes(withinLoadSavings)}${pctOf(withinLoadSavings)}**`,
|
|
760
|
+
];
|
|
761
|
+
// Split the within-load figure at the canonical 128-char cap so the
|
|
762
|
+
// reported number matches what the recommended fix actually reclaims
|
|
763
|
+
// (feedback §3). Only shown when there's over-cap value to distinguish —
|
|
764
|
+
// otherwise the whole within-load figure is already cappable.
|
|
765
|
+
if (withinOverCap > 0) {
|
|
766
|
+
headerLines.push(` - ≤${CANONICAL_CAP_CHARS}-char cappable (what the canonical 128-char intern fix reclaims): ${formatBytes(withinCappable)}`, ` - >${CANONICAL_CAP_CHARS}-char over-cap (skipped by the 128-char cap — needs an uncapped pool or a different fix): ${formatBytes(withinOverCap)}`);
|
|
767
|
+
}
|
|
768
|
+
if (withinArrayElementCappable > 0) {
|
|
769
|
+
headerLines.push(` - ▦ of which columnar / array-element (rowsAsArray) duplication (cappable): ${formatBytes(withinArrayElementCappable)} — intern at the array-construction/parse site`);
|
|
770
|
+
}
|
|
771
|
+
if (coRetainedSavings > 0) {
|
|
772
|
+
headerLines.push(`- **⚠ Co-retained (interning the property reclaims ~0 — these instances are ALSO held by an independent structure, e.g. a raw array/matrix on another object; dedupe at that shared source or drop it): ${formatBytes(coRetainedSavings)}${pctOf(coRetainedSavings)}**`);
|
|
773
|
+
}
|
|
774
|
+
if (crossLoadSavings > 0) {
|
|
775
|
+
headerLines.push(`- **⤫ Cross-load (NOT capturable by a per-request pool — needs a shared/module-scope pool or a retention/concurrency fix): ${formatBytes(crossLoadSavings)}${pctOf(crossLoadSavings)}**`);
|
|
776
|
+
}
|
|
777
|
+
if (frameworkSavings > 0) {
|
|
778
|
+
headerLines.push(`- **▤ Framework/infra-owned (HTTP headers, cookies, auth tokens, Next.js URL/cache context — not app data; out of scope for an app-level intern pool): ${formatBytes(frameworkSavings)}${pctOf(frameworkSavings)}**`);
|
|
779
|
+
}
|
|
780
|
+
if (lowRoiSavings > 0) {
|
|
781
|
+
headerLines.push(`- **▽ Low-ROI (high-cardinality, few repeats + long strings — a per-load pool retains a large unique set for a small collapse; usually skip): ${formatBytes(lowRoiSavings)}${pctOf(lowRoiSavings)}**`);
|
|
782
|
+
}
|
|
783
|
+
if (concatStringCount > CONCAT_STRING_BUILDUP_FLOOR) {
|
|
784
|
+
headerLines.push('', `⚠ **Concatenated-string buildup:** ${formatNumber(concatStringCount)} \`(concatenated string)\` nodes (~${formatBytes(concatStringSize)} self-size) — a rope/accumulation pattern (repeated \`+=\` / join into a long-lived buffer), NOT value duplication. Interning cannot help; investigate with \`memlab_largest_objects\` / \`memlab_sliced_strings\` and trace the retainer.`);
|
|
785
|
+
}
|
|
786
|
+
const coRetainedGroups = shown.filter(g => g.coRetained);
|
|
787
|
+
const hasArrayElement = shown.some(g => g.arrayElement);
|
|
788
|
+
const lines = [
|
|
789
|
+
...headerLines,
|
|
790
|
+
'',
|
|
791
|
+
markdownTable(headers, rows, rightCols),
|
|
792
|
+
'',
|
|
793
|
+
];
|
|
794
|
+
if (coRetainedGroups.length > 0 ||
|
|
795
|
+
crossLoadSavings > 0 ||
|
|
796
|
+
frameworkSavings > 0 ||
|
|
797
|
+
lowRoiSavings > 0 ||
|
|
798
|
+
hasArrayElement) {
|
|
799
|
+
lines.push("▦ = array element (columnar / rowsAsArray — intern at the parse site); ⚠ = co-retained (interning won't reclaim); ⤫ = cross-load (high Dup ×, needs shared pool); ▤ = framework/infra-owned (not app data); ▽ = low-ROI (high-cardinality + long; usually skip).", '');
|
|
800
|
+
}
|
|
801
|
+
// Triage mode: stop after the headline split + ranked table. Drops the
|
|
802
|
+
// per-group string lists, partial-interning detail, and the fix recipe
|
|
803
|
+
// — pure waste when screening many snapshots (Feedback round 5 §9).
|
|
804
|
+
if (summary_only) {
|
|
805
|
+
if (coRetainedGroups.length > 0) {
|
|
806
|
+
lines.push('## ⚠ Co-retained — interning the property will NOT reclaim these', '');
|
|
807
|
+
}
|
|
808
|
+
for (const g of coRetainedGroups) {
|
|
809
|
+
const shape = g.parentShape.length > 50
|
|
810
|
+
? g.parentShape.slice(0, 47) + '…}'
|
|
811
|
+
: g.parentShape;
|
|
812
|
+
lines.push(`- ⚠ \`.${g.propertyName}\` on \`${shape}\` — ${formatBytes(g.savingsIfInterned)} co-retained via **${g.coRetainedVia ?? 'another structure'}**`);
|
|
813
|
+
}
|
|
814
|
+
return toolResult(lines.join('\n'));
|
|
815
|
+
}
|
|
816
|
+
// Co-retained groups: interning the property frees ~0 (Feedback round 4 §1).
|
|
817
|
+
if (coRetainedGroups.length > 0) {
|
|
818
|
+
lines.push('## ⚠ Co-retained — interning the property will NOT reclaim these', '', 'Each duplicated value below is also referenced by another structure, so deduping it at the property assignment site frees ~nothing — the other referrer keeps every per-row instance alive. Dedupe at the **shared source** instead (e.g. intern the raw array/matrix cells at ingestion, or drop that structure once parsed). Confirm with `memlab_get_referrers` on an example instance.', '');
|
|
819
|
+
for (const g of coRetainedGroups) {
|
|
820
|
+
const shape = g.parentShape.length > 50
|
|
821
|
+
? g.parentShape.slice(0, 47) + '…}'
|
|
822
|
+
: g.parentShape;
|
|
823
|
+
lines.push(`- \`.${g.propertyName}\` on \`${shape}\` — ${formatBytes(g.savingsIfInterned)}; each instance also retained via **${g.coRetainedVia ?? 'another structure'}**`);
|
|
824
|
+
}
|
|
825
|
+
lines.push('');
|
|
826
|
+
}
|
|
827
|
+
// Show top strings for the top 5 groups
|
|
828
|
+
for (const g of shown.slice(0, 5)) {
|
|
829
|
+
lines.push(`### \`.${g.propertyName}\` on \`${g.parentShape.length > 60 ? g.parentShape.slice(0, 57) + '…}' : g.parentShape}\` — ${formatBytes(g.savingsIfInterned)} savings`);
|
|
830
|
+
for (const s of g.topStrings) {
|
|
831
|
+
const val = s.value.length > 60 ? s.value.slice(0, 60) + '…' : s.value;
|
|
832
|
+
lines.push(`- "${val}" × ${formatNumber(s.count)} copies (${formatBytes(s.size)})`);
|
|
833
|
+
}
|
|
834
|
+
lines.push('');
|
|
835
|
+
}
|
|
836
|
+
if (partialInternAlerts.length > 0) {
|
|
837
|
+
lines.push('## Partial Interning Detected', '', 'Strings that are partially deduplicated — interned within each dataset/call but duplicated across them:', '', ...partialInternAlerts, '', '_This typically happens when `internStrings()` or a dedup function creates a new Map per call instead of sharing one across datasets. Fix: lift the intern pool to module scope or pass it as a parameter._', '');
|
|
838
|
+
}
|
|
839
|
+
// The fix recipe + next steps are "suggestions"; honor the session-level
|
|
840
|
+
// suppress flag so a long sweep doesn't repeat the same boilerplate on
|
|
841
|
+
// every snapshot (Feedback round 5 §9a).
|
|
842
|
+
if (!getSessionConfig().suppressSuggestions) {
|
|
843
|
+
lines.push('---', '', '**How to fix:** Add a string interning pool at the JSON.parse / API response boundary:', '```js', 'const internPool = new Map();', 'function intern(s) { let v = internPool.get(s); if (!v) { internPool.set(s, s); v = s; } return v; }', '// Apply to the property during ingestion:', '// obj.propertyName = intern(obj.propertyName);', '```', '', '**Next steps:**', `- Inspect example parent: \`memlab_object_shape(${shown[0].exampleParentId})\``,
|
|
844
|
+
// find_by_shape needs a property-shape fingerprint; array-element
|
|
845
|
+
// (columnar) top groups have none (parentShapeProps is empty), so the
|
|
846
|
+
// recipe would render an unactionable `properties []`. Emit it only for
|
|
847
|
+
// a named-property top group — the columnar case is covered by the
|
|
848
|
+
// array-element step below.
|
|
849
|
+
...(!shown[0].arrayElement && shown[0].parentShapeProps.length > 0
|
|
850
|
+
? [
|
|
851
|
+
`- Find all instances: \`memlab_find_by_shape\` with properties ${JSON.stringify(shown[0].parentShapeProps.slice(0, 5))}`,
|
|
852
|
+
]
|
|
853
|
+
: []), '- Search codebase for the constructor/factory that creates these objects', ...(hasArrayElement
|
|
854
|
+
? [
|
|
855
|
+
'- For ▦ array-element (columnar / rowsAsArray) groups: the strings are cells of a result buffer, not object properties. Intern each cell where the rows are built (the DB driver’s rowsAsArray mapping or the parse loop) — e.g. `row[col] = intern(row[col])` per duplicated column — not at a property assignment site.',
|
|
856
|
+
]
|
|
857
|
+
: []), ...(coRetainedGroups.length > 0
|
|
858
|
+
? [
|
|
859
|
+
'- For ⚠ co-retained groups: run `memlab_get_referrers` on an example instance to find the shared owner, then dedupe at that source (the property-level pool above will not help).',
|
|
860
|
+
]
|
|
861
|
+
: []));
|
|
862
|
+
}
|
|
863
|
+
return toolResult(lines.join('\n'));
|
|
864
|
+
}
|
|
865
|
+
catch (err) {
|
|
866
|
+
return errorResult(err);
|
|
867
|
+
}
|
|
868
|
+
});
|
|
869
|
+
}
|
|
870
|
+
//# sourceMappingURL=intern-opportunities.js.map
|