brass-runtime 1.16.0 → 1.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +17 -0
- package/README.md +287 -23
- package/dist/agent/cli/main.cjs +38 -38
- package/dist/agent/cli/main.js +6 -6
- package/dist/agent/cli/main.mjs +6 -6
- package/dist/agent/index.cjs +7 -7
- package/dist/agent/index.d.ts +1 -1
- package/dist/agent/index.js +6 -6
- package/dist/agent/index.mjs +6 -6
- package/dist/chunk-2HQTDLHF.mjs +683 -0
- package/dist/chunk-36I3M4UC.mjs +370 -0
- package/dist/{chunk-QY5FKYEQ.js → chunk-3AYM6WPJ.js} +570 -51
- package/dist/chunk-3LOYJFRR.cjs +300 -0
- package/dist/chunk-3Y2RIUMM.js +300 -0
- package/dist/{chunk-7XOPAB5Q.js → chunk-4P2HHGAX.mjs} +83 -5
- package/dist/{chunk-N6VHMOWB.cjs → chunk-4ROBZFL6.cjs} +128 -128
- package/dist/{chunk-NC5SDRYE.js → chunk-52OB2ROS.js} +4 -4
- package/dist/{chunk-JX3LZQJH.cjs → chunk-52PPNNI4.cjs} +82 -20
- package/dist/{chunk-5YOQOXEQ.cjs → chunk-5EC274J5.cjs} +676 -293
- package/dist/chunk-5QC7LRZ3.js +229 -0
- package/dist/{chunk-7TL2LHQJ.js → chunk-5VRJNBLZ.mjs} +524 -141
- package/dist/chunk-62AZW6UT.cjs +313 -0
- package/dist/chunk-6IXXWIUM.js +683 -0
- package/dist/chunk-6RY2FFN4.mjs +2024 -0
- package/dist/chunk-74ZTY6CP.js +2871 -0
- package/dist/chunk-7CMJS3QE.mjs +2871 -0
- package/dist/{chunk-2WC63LJK.mjs → chunk-7JIJOVCT.js} +20 -10
- package/dist/chunk-7X3K5RMS.js +2024 -0
- package/dist/chunk-7ZPEZ57L.cjs +2024 -0
- package/dist/{chunk-FM4W4QPL.js → chunk-A2OM6NEH.mjs} +5 -4
- package/dist/chunk-AGR5B2BC.cjs +683 -0
- package/dist/chunk-B33ICAKP.js +313 -0
- package/dist/{chunk-J3H54ZRV.mjs → chunk-B5JD23U7.mjs} +1 -1
- package/dist/{chunk-F5EUMJL7.mjs → chunk-BKK77SBA.js} +83 -5
- package/dist/{chunk-U5KWK3PX.mjs → chunk-C3MDXTRZ.js} +11 -0
- package/dist/{chunk-SPUEME2B.cjs → chunk-CZIVE6NT.cjs} +12 -1
- package/dist/{chunk-TDVMADDN.js → chunk-DNFJLJMW.mjs} +11 -0
- package/dist/{chunk-XDZOO4L5.js → chunk-EJ6BPYVR.mjs} +79 -17
- package/dist/chunk-EOC4UHBS.mjs +229 -0
- package/dist/chunk-F6XWZQY4.cjs +777 -0
- package/dist/{chunk-7LVI2GIN.js → chunk-FH2X7BVP.js} +507 -72
- package/dist/{chunk-OOGJ73B6.js → chunk-FHQGHPMO.mjs} +20 -10
- package/dist/{chunk-WQ5QNU5R.cjs → chunk-GLE2WY7Z.cjs} +652 -217
- package/dist/{chunk-G6IQOE4P.mjs → chunk-GYM3LLGS.mjs} +507 -72
- package/dist/{chunk-TVN5I4U6.cjs → chunk-JF5WGYJJ.cjs} +25 -24
- package/dist/{chunk-CY33PGEX.mjs → chunk-KH4SYAOS.mjs} +570 -51
- package/dist/chunk-KN32XNTH.mjs +313 -0
- package/dist/chunk-KQLYONSE.cjs +2871 -0
- package/dist/{chunk-7HUOJA4W.cjs → chunk-KZJQ723N.cjs} +90 -80
- package/dist/{chunk-CCKHV5BT.mjs → chunk-L2SYFEBS.js} +5 -4
- package/dist/{chunk-IJT6RRQ5.cjs → chunk-L6VB5N7Q.cjs} +20 -9
- package/dist/{chunk-ZGLD4TVZ.mjs → chunk-MBEJI5HF.mjs} +4 -4
- package/dist/{chunk-PRWCB3QL.mjs → chunk-MIIYDLGM.js} +524 -141
- package/dist/{chunk-H55LI6WY.js → chunk-MOO4L7F4.mjs} +15 -4
- package/dist/chunk-MVGUEJ5Z.cjs +370 -0
- package/dist/chunk-PD4EJTQC.cjs +229 -0
- package/dist/chunk-PWC3RBQE.mjs +300 -0
- package/dist/{chunk-MWXMNYJS.cjs → chunk-Q2I37RP3.cjs} +643 -124
- package/dist/{chunk-VFIUZG7J.mjs → chunk-RKGKFN2A.js} +79 -17
- package/dist/{chunk-NYL4D7SK.cjs → chunk-SA6HUJVI.cjs} +5 -5
- package/dist/chunk-SK7UZRNI.mjs +777 -0
- package/dist/{chunk-K2T3DV26.mjs → chunk-TRM4JUZQ.js} +15 -4
- package/dist/chunk-UB4B6OFY.js +370 -0
- package/dist/{chunk-G3XGCZDQ.js → chunk-UCUBNWM2.js} +1 -1
- package/dist/chunk-VWIPB6I5.js +777 -0
- package/dist/{chunk-JNFRRJYH.cjs → chunk-WBGRHGBP.cjs} +270 -192
- package/dist/{client-CtFmoDvM.d.ts → client-CZHU674n.d.ts} +211 -36
- package/dist/core/index.cjs +135 -9
- package/dist/core/index.d.ts +238 -33
- package/dist/core/index.js +155 -29
- package/dist/core/index.mjs +155 -29
- package/dist/{effect-CGNl5Rqp.d.ts → effect-DIUHZ9IN.d.ts} +89 -1
- package/dist/effectRunner-CFLC32IK.cjs +8 -0
- package/dist/{effectRunner-A4CHJXJI.js → effectRunner-L4S7IPT3.js} +2 -2
- package/dist/{effectRunner-OPUF6QRN.mjs → effectRunner-NNGG75QA.mjs} +2 -2
- package/dist/http/index.cjs +324 -2986
- package/dist/http/index.d.ts +54 -68
- package/dist/http/index.js +238 -2900
- package/dist/http/index.mjs +238 -2900
- package/dist/http/testing.cjs +14 -12
- package/dist/http/testing.d.ts +5 -4
- package/dist/http/testing.js +10 -8
- package/dist/http/testing.mjs +10 -8
- package/dist/index.cjs +423 -255
- package/dist/index.d.ts +87 -69
- package/dist/index.js +301 -133
- package/dist/index.mjs +301 -133
- package/dist/observability/index.cjs +18 -531
- package/dist/observability/index.d.ts +81 -8
- package/dist/observability/index.js +25 -538
- package/dist/observability/index.mjs +25 -538
- package/dist/perf/cli.cjs +401 -0
- package/dist/perf/cli.d.ts +1 -0
- package/dist/perf/cli.js +401 -0
- package/dist/perf/cli.mjs +401 -0
- package/dist/perf/index.cjs +141 -0
- package/dist/perf/index.d.ts +483 -0
- package/dist/perf/index.js +141 -0
- package/dist/perf/index.mjs +141 -0
- package/dist/schedule-CK3Ml_7p.d.ts +259 -0
- package/dist/schema/index.cjs +6 -2
- package/dist/schema/index.d.ts +3 -1
- package/dist/schema/index.js +5 -1
- package/dist/schema/index.mjs +5 -1
- package/dist/{server-C8hDXA74.d.ts → server-D6JZ15_e.d.ts} +16 -4
- package/dist/{stream-dvSs0QS5.d.ts → stream-B4oK9JFP.d.ts} +1 -1
- package/dist/{tracer-B5tRH9H7.d.ts → tracer-Hwt1cl7h.d.ts} +13 -54
- package/dist/{tracing-Dt9S_6V8.d.ts → tracing-DqbTKGcf.d.ts} +1 -1
- package/docs/ARCHITECTURE.md +292 -0
- package/docs/README.md +65 -0
- package/docs/adr/0001-ai-context-pack.md +32 -0
- package/docs/agent-apply-mode.md +104 -0
- package/docs/agent-approvals.md +110 -0
- package/docs/agent-batch.md +185 -0
- package/docs/agent-boundaries.md +112 -0
- package/docs/agent-chat-sessions.md +160 -0
- package/docs/agent-ci.md +17 -0
- package/docs/agent-cli.md +405 -0
- package/docs/agent-config.md +480 -0
- package/docs/agent-context-discovery.md +159 -0
- package/docs/agent-copilot-like-dx.md +126 -0
- package/docs/agent-declarative-optimized-planning.md +138 -0
- package/docs/agent-dx.md +224 -0
- package/docs/agent-env-files.md +126 -0
- package/docs/agent-follow-up-context.md +43 -0
- package/docs/agent-global-usage.md +180 -0
- package/docs/agent-init.md +109 -0
- package/docs/agent-install-and-configure.md +516 -0
- package/docs/agent-language-workspace-ux.md +99 -0
- package/docs/agent-llm-adapters.md +123 -0
- package/docs/agent-local-install.md +190 -0
- package/docs/agent-local-tests.md +51 -0
- package/docs/agent-observability.md +155 -0
- package/docs/agent-patch-quality-loop.md +162 -0
- package/docs/agent-presets.md +22 -0
- package/docs/agent-project-commands.md +237 -0
- package/docs/agent-project-intelligence.md +156 -0
- package/docs/agent-redaction.md +18 -0
- package/docs/agent-release-readiness.md +76 -0
- package/docs/agent-rollback-safety.md +162 -0
- package/docs/agent-rollback.md +23 -0
- package/docs/agent-run-artifacts.md +16 -0
- package/docs/agent-vscode-auto-discovery.md +137 -0
- package/docs/agent-vscode-batch-runner.md +100 -0
- package/docs/agent-vscode-chat-layout.md +90 -0
- package/docs/agent-vscode-clean-install.md +147 -0
- package/docs/agent-vscode-code-actions.md +70 -0
- package/docs/agent-vscode-diff-preview.md +45 -0
- package/docs/agent-vscode-inline-assist.md +56 -0
- package/docs/agent-vscode-install.md +186 -0
- package/docs/agent-vscode-model-setup.md +97 -0
- package/docs/agent-vscode-patch-preview.md +92 -0
- package/docs/agent-vscode-problems.md +79 -0
- package/docs/agent-vscode-project-dashboard.md +106 -0
- package/docs/agent-vscode-run-history.md +92 -0
- package/docs/agent-vscode-ux.md +73 -0
- package/docs/ai/INVARIANTS.md +84 -0
- package/docs/ai/PROJECT_MAP.md +338 -0
- package/docs/ai/PUBLIC_API.md +339 -0
- package/docs/ai/VALIDATION_MATRIX.md +67 -0
- package/docs/api-polish.md +37 -0
- package/docs/cancellation.md +162 -0
- package/docs/coverage.md +46 -0
- package/docs/framework-integrations.md +38 -0
- package/docs/frameworks/angular.md +153 -0
- package/docs/frameworks/express.md +125 -0
- package/docs/frameworks/fastify.md +124 -0
- package/docs/frameworks/nestjs.md +282 -0
- package/docs/frameworks/nextjs.md +147 -0
- package/docs/frameworks/react.md +139 -0
- package/docs/frameworks/vanilla.md +224 -0
- package/docs/getting-started.md +159 -0
- package/docs/guides/README.md +40 -0
- package/docs/guides/circuit-breaker.md +89 -0
- package/docs/guides/error-handling.md +91 -0
- package/docs/guides/getting-started.md +107 -0
- package/docs/guides/layers.md +189 -0
- package/docs/guides/metrics.md +101 -0
- package/docs/guides/resource-management.md +141 -0
- package/docs/guides/retry.md +215 -0
- package/docs/guides/semaphore.md +66 -0
- package/docs/guides/streams.md +117 -0
- package/docs/guides/supervisors.md +98 -0
- package/docs/guides/testing.md +162 -0
- package/docs/guides/tracing.md +71 -0
- package/docs/http-recipes.md +399 -0
- package/docs/http.md +749 -0
- package/docs/modules.md +285 -0
- package/docs/nestjs.md +6 -0
- package/docs/observability-collector-smoke.md +31 -0
- package/docs/observability-framework-examples.md +110 -0
- package/docs/observability.md +649 -0
- package/docs/otel-collector-smoke.yaml +27 -0
- package/docs/performance-profiler.md +199 -0
- package/docs/production-readiness.md +73 -0
- package/docs/recipes/README.md +12 -0
- package/docs/recipes/http-server.md +45 -0
- package/docs/recipes/layers.md +44 -0
- package/docs/recipes/performance.md +47 -0
- package/docs/recipes/runtime.md +41 -0
- package/docs/recipes/testing.md +41 -0
- package/docs/release.md +53 -0
- package/docs/wasm-bounded-queues.md +44 -0
- package/docs/wasm-engine-observability-benchmarks.md +85 -0
- package/docs/wasm-fiber-engine.md +117 -0
- package/docs/wasm-scheduler-state-machine.md +122 -0
- package/docs/wasm-stream-chunks.md +54 -0
- package/package.json +22 -2
- package/dist/chunk-45F7OKGT.cjs +0 -104
- package/dist/chunk-7V4KY4RL.mjs +0 -104
- package/dist/chunk-DJQ7OMMB.cjs +0 -144
- package/dist/chunk-GOV47PPB.mjs +0 -552
- package/dist/chunk-JF4XXPZ5.cjs +0 -552
- package/dist/chunk-KCPT2D6G.js +0 -552
- package/dist/chunk-NOYZIMUJ.mjs +0 -144
- package/dist/chunk-PNVFW245.js +0 -144
- package/dist/chunk-ROJC3NBJ.js +0 -104
- package/dist/effectRunner-3ZHAD3LE.cjs +0 -8
- package/dist/schedule-Fque9Abz.d.ts +0 -70
|
@@ -0,0 +1,2024 @@
|
|
|
1
|
+
import {
|
|
2
|
+
makeFiberRef,
|
|
3
|
+
makeRuntimeRecorder
|
|
4
|
+
} from "./chunk-5QC7LRZ3.js";
|
|
5
|
+
import {
|
|
6
|
+
makeDefaultHttpClient
|
|
7
|
+
} from "./chunk-74ZTY6CP.js";
|
|
8
|
+
import {
|
|
9
|
+
makeHttp
|
|
10
|
+
} from "./chunk-MIIYDLGM.js";
|
|
11
|
+
import {
|
|
12
|
+
withHttpObservability
|
|
13
|
+
} from "./chunk-VWIPB6I5.js";
|
|
14
|
+
import {
|
|
15
|
+
makeObservability
|
|
16
|
+
} from "./chunk-BKK77SBA.js";
|
|
17
|
+
import {
|
|
18
|
+
EventBus
|
|
19
|
+
} from "./chunk-RKGKFN2A.js";
|
|
20
|
+
import {
|
|
21
|
+
Runtime,
|
|
22
|
+
Scheduler
|
|
23
|
+
} from "./chunk-FH2X7BVP.js";
|
|
24
|
+
import {
|
|
25
|
+
asyncFlatMap,
|
|
26
|
+
asyncSucceed,
|
|
27
|
+
asyncSync
|
|
28
|
+
} from "./chunk-UB4B6OFY.js";
|
|
29
|
+
|
|
30
|
+
// src/perf/recorder.ts
|
|
31
|
+
var DEFAULT_MAX_EVENTS = 2048;
|
|
32
|
+
function makePerfRecorder(options = {}) {
|
|
33
|
+
const capacity = normalizeCapacity(options.maxEvents);
|
|
34
|
+
const clock = options.clock ?? defaultClock;
|
|
35
|
+
const buffer = new Array(capacity);
|
|
36
|
+
let writeIndex = 0;
|
|
37
|
+
let size = 0;
|
|
38
|
+
let recorded = 0;
|
|
39
|
+
let dropped = 0;
|
|
40
|
+
const record = (input) => {
|
|
41
|
+
const event = freezeEvent({
|
|
42
|
+
...input,
|
|
43
|
+
timestamp: input.timestamp ?? clock()
|
|
44
|
+
});
|
|
45
|
+
if (size === capacity) {
|
|
46
|
+
dropped++;
|
|
47
|
+
} else {
|
|
48
|
+
size++;
|
|
49
|
+
}
|
|
50
|
+
buffer[writeIndex] = event;
|
|
51
|
+
writeIndex = (writeIndex + 1) % capacity;
|
|
52
|
+
recorded++;
|
|
53
|
+
return event;
|
|
54
|
+
};
|
|
55
|
+
const snapshot = () => {
|
|
56
|
+
const out = new Array(size);
|
|
57
|
+
const start = size === capacity ? writeIndex : 0;
|
|
58
|
+
for (let i = 0; i < size; i++) {
|
|
59
|
+
out[i] = buffer[(start + i) % capacity];
|
|
60
|
+
}
|
|
61
|
+
return Object.freeze(out);
|
|
62
|
+
};
|
|
63
|
+
return {
|
|
64
|
+
record,
|
|
65
|
+
mark: (name, details, tags) => record({ type: "mark", name, details, tags }),
|
|
66
|
+
measure: (name, fn, details, tags) => {
|
|
67
|
+
const startedAt = clock();
|
|
68
|
+
try {
|
|
69
|
+
return fn();
|
|
70
|
+
} finally {
|
|
71
|
+
record({
|
|
72
|
+
type: "measure",
|
|
73
|
+
name,
|
|
74
|
+
timestamp: clock(),
|
|
75
|
+
durationMs: round(clock() - startedAt),
|
|
76
|
+
details,
|
|
77
|
+
tags
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
measureAsync: async (name, fn, details, tags) => {
|
|
82
|
+
const startedAt = clock();
|
|
83
|
+
try {
|
|
84
|
+
return await fn();
|
|
85
|
+
} finally {
|
|
86
|
+
record({
|
|
87
|
+
type: "measure",
|
|
88
|
+
name,
|
|
89
|
+
timestamp: clock(),
|
|
90
|
+
durationMs: round(clock() - startedAt),
|
|
91
|
+
details,
|
|
92
|
+
tags
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
counter: (name, value = 1, unit, tags) => record({ type: "counter", name, value, unit, tags }),
|
|
97
|
+
gauge: (name, value, unit, tags) => record({ type: "gauge", name, value, unit, tags }),
|
|
98
|
+
snapshot,
|
|
99
|
+
stats: () => Object.freeze({ capacity, size, recorded, dropped }),
|
|
100
|
+
explain: () => summarizePerfEvents(snapshot()),
|
|
101
|
+
clear: () => {
|
|
102
|
+
buffer.fill(void 0);
|
|
103
|
+
writeIndex = 0;
|
|
104
|
+
size = 0;
|
|
105
|
+
recorded = 0;
|
|
106
|
+
dropped = 0;
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
function summarizePerfEvents(events) {
|
|
111
|
+
const summaries = /* @__PURE__ */ new Map();
|
|
112
|
+
for (const event of events) {
|
|
113
|
+
const key = `${event.type}:${event.name}`;
|
|
114
|
+
const current = summaries.get(key) ?? {
|
|
115
|
+
name: event.name,
|
|
116
|
+
type: event.type,
|
|
117
|
+
count: 0,
|
|
118
|
+
totalDurationMs: 0,
|
|
119
|
+
maxDurationMs: 0,
|
|
120
|
+
lastTimestamp: event.timestamp
|
|
121
|
+
};
|
|
122
|
+
const durationMs = event.durationMs ?? 0;
|
|
123
|
+
current.count++;
|
|
124
|
+
current.totalDurationMs = round(current.totalDurationMs + durationMs);
|
|
125
|
+
current.maxDurationMs = Math.max(current.maxDurationMs, durationMs);
|
|
126
|
+
current.lastTimestamp = event.timestamp;
|
|
127
|
+
if (event.value !== void 0) current.lastValue = event.value;
|
|
128
|
+
if (event.unit !== void 0) current.unit = event.unit;
|
|
129
|
+
summaries.set(key, current);
|
|
130
|
+
}
|
|
131
|
+
return Object.freeze([...summaries.values()].map((summary) => Object.freeze({
|
|
132
|
+
...summary,
|
|
133
|
+
totalDurationMs: round(summary.totalDurationMs),
|
|
134
|
+
maxDurationMs: round(summary.maxDurationMs)
|
|
135
|
+
})).sort((a, b) => a.name.localeCompare(b.name) || a.type.localeCompare(b.type)));
|
|
136
|
+
}
|
|
137
|
+
function freezeEvent(event) {
|
|
138
|
+
return Object.freeze({
|
|
139
|
+
...event,
|
|
140
|
+
durationMs: event.durationMs === void 0 ? void 0 : round(event.durationMs),
|
|
141
|
+
tags: event.tags ? Object.freeze({ ...event.tags }) : void 0,
|
|
142
|
+
details: event.details ? Object.freeze({ ...event.details }) : void 0
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
function normalizeCapacity(value) {
|
|
146
|
+
if (value === void 0) return DEFAULT_MAX_EVENTS;
|
|
147
|
+
if (!Number.isFinite(value)) return DEFAULT_MAX_EVENTS;
|
|
148
|
+
return Math.max(1, Math.floor(value));
|
|
149
|
+
}
|
|
150
|
+
function defaultClock() {
|
|
151
|
+
return performance.now();
|
|
152
|
+
}
|
|
153
|
+
function round(n) {
|
|
154
|
+
return Math.round(n * 1e3) / 1e3;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// src/perf/memory.ts
|
|
158
|
+
function hasGc() {
|
|
159
|
+
return typeof globalThis.gc === "function";
|
|
160
|
+
}
|
|
161
|
+
function forceGc(passes = 2) {
|
|
162
|
+
const gc = globalThis.gc;
|
|
163
|
+
if (typeof gc !== "function") return false;
|
|
164
|
+
const count = Math.max(1, Math.floor(passes));
|
|
165
|
+
for (let i = 0; i < count; i++) gc();
|
|
166
|
+
return true;
|
|
167
|
+
}
|
|
168
|
+
function captureMemorySnapshot(options = {}) {
|
|
169
|
+
if (options.forceGc) forceGc(options.gcPasses);
|
|
170
|
+
const usage = process.memoryUsage();
|
|
171
|
+
const clock = options.clock ?? Date.now;
|
|
172
|
+
return Object.freeze({
|
|
173
|
+
timestamp: clock(),
|
|
174
|
+
heapUsedMb: toMb(usage.heapUsed),
|
|
175
|
+
heapTotalMb: toMb(usage.heapTotal),
|
|
176
|
+
rssMb: toMb(usage.rss),
|
|
177
|
+
externalMb: toMb(usage.external),
|
|
178
|
+
arrayBuffersMb: toMb(usage.arrayBuffers),
|
|
179
|
+
gcAvailable: hasGc()
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
function diffMemorySnapshots(before, after) {
|
|
183
|
+
return Object.freeze({
|
|
184
|
+
heapUsedMb: round2(after.heapUsedMb - before.heapUsedMb),
|
|
185
|
+
heapTotalMb: round2(after.heapTotalMb - before.heapTotalMb),
|
|
186
|
+
rssMb: round2(after.rssMb - before.rssMb),
|
|
187
|
+
externalMb: round2(after.externalMb - before.externalMb),
|
|
188
|
+
arrayBuffersMb: round2(after.arrayBuffersMb - before.arrayBuffersMb)
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
async function profileMemoryRetention(fn, options = {}) {
|
|
192
|
+
const clock = options.clock ?? performance.now.bind(performance);
|
|
193
|
+
const before = captureMemorySnapshot(options);
|
|
194
|
+
const startedAt = clock();
|
|
195
|
+
const value = await fn();
|
|
196
|
+
const durationMs = round2(clock() - startedAt);
|
|
197
|
+
const after = captureMemorySnapshot(options);
|
|
198
|
+
const report = Object.freeze({
|
|
199
|
+
label: options.label ?? "memory-retention",
|
|
200
|
+
durationMs,
|
|
201
|
+
before,
|
|
202
|
+
after,
|
|
203
|
+
delta: diffMemorySnapshots(before, after)
|
|
204
|
+
});
|
|
205
|
+
return Object.freeze({ value, report });
|
|
206
|
+
}
|
|
207
|
+
function toMb(bytes) {
|
|
208
|
+
return round2(bytes / (1024 * 1024));
|
|
209
|
+
}
|
|
210
|
+
function round2(n) {
|
|
211
|
+
return Math.round(n * 1e3) / 1e3;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// src/perf/runtimeProfiler.ts
|
|
215
|
+
async function profileRuntimePrimitives(options = {}) {
|
|
216
|
+
const iterations = positiveInt(options.iterations, 5e3);
|
|
217
|
+
const chainDepth = positiveInt(options.chainDepth, 1e3);
|
|
218
|
+
const variant = options.variant ?? "default";
|
|
219
|
+
const runtimeCase = makeRuntimeCase(variant, chainDepth);
|
|
220
|
+
const runtime = runtimeCase.runtime;
|
|
221
|
+
const runMode = variant === "fiber-only" ? "fiber" : "auto";
|
|
222
|
+
const results = [];
|
|
223
|
+
results.push(await measureEffect("async-succeed/top-level", iterations, "effect", runtime, runMode, "success", () => asyncSucceed(1)));
|
|
224
|
+
results.push(await measureEffect("async-fail/top-level", iterations, "effect", runtime, runMode, "failure", () => asyncFailProgram()));
|
|
225
|
+
results.push(await measureEffect("async-sync/top-level", iterations, "effect", runtime, runMode, "success", () => asyncSync(() => 1)));
|
|
226
|
+
const flatMapRounds = Math.max(1, Math.floor(iterations / Math.max(1, chainDepth)));
|
|
227
|
+
results.push(await measureEffect(
|
|
228
|
+
"flatMap-chain",
|
|
229
|
+
flatMapRounds * chainDepth,
|
|
230
|
+
"operation",
|
|
231
|
+
runtime,
|
|
232
|
+
runMode,
|
|
233
|
+
"success",
|
|
234
|
+
() => makeFlatMapChain(chainDepth)
|
|
235
|
+
));
|
|
236
|
+
const fiberRefRounds = Math.max(1, Math.floor(iterations / Math.max(1, chainDepth)));
|
|
237
|
+
results.push(await measureEffect(
|
|
238
|
+
"fiberRef-update-get",
|
|
239
|
+
fiberRefRounds * chainDepth,
|
|
240
|
+
"operation",
|
|
241
|
+
runtime,
|
|
242
|
+
runMode,
|
|
243
|
+
"success",
|
|
244
|
+
() => makeFiberRefProgram(chainDepth)
|
|
245
|
+
));
|
|
246
|
+
for (const result of results) {
|
|
247
|
+
options.recorder?.gauge("runtime.ops-per-sec", result.operationsPerSecond, "ops/s", { primitive: result.name });
|
|
248
|
+
}
|
|
249
|
+
await runtime.shutdown();
|
|
250
|
+
return Object.freeze({
|
|
251
|
+
variant,
|
|
252
|
+
label: runtimeCase.label,
|
|
253
|
+
iterations,
|
|
254
|
+
chainDepth,
|
|
255
|
+
hooksActive: runtime.hasActiveHooks(),
|
|
256
|
+
recorderEvents: runtimeCase.recorder?.stats().size,
|
|
257
|
+
scheduler: runtimeCase.scheduler,
|
|
258
|
+
results: Object.freeze(results)
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
async function measureEffect(name, units, unit, runtime, runMode, expected, makeEffect) {
|
|
262
|
+
const fibersBefore = startedFibers(runtime);
|
|
263
|
+
const startedAt = performance.now();
|
|
264
|
+
if (unit === "effect") {
|
|
265
|
+
for (let i = 0; i < units; i++) {
|
|
266
|
+
await runExpectedEffect(runtime, makeEffect(), runMode, expected);
|
|
267
|
+
}
|
|
268
|
+
} else {
|
|
269
|
+
await runExpectedEffect(runtime, makeEffect(), runMode, expected);
|
|
270
|
+
}
|
|
271
|
+
const durationMs = round3(performance.now() - startedAt);
|
|
272
|
+
const fibersStarted = Math.max(0, startedFibers(runtime) - fibersBefore);
|
|
273
|
+
return Object.freeze({
|
|
274
|
+
name,
|
|
275
|
+
units,
|
|
276
|
+
unit,
|
|
277
|
+
durationMs,
|
|
278
|
+
nsPerOperation: round3(durationMs * 1e6 / Math.max(units, 1)),
|
|
279
|
+
operationsPerSecond: round3(units / Math.max(durationMs / 1e3, 1e-3)),
|
|
280
|
+
fibersStarted,
|
|
281
|
+
fibersPerThousandOps: round3(fibersStarted / Math.max(units, 1) * 1e3)
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
async function runExpectedEffect(runtime, effect, runMode, expected) {
|
|
285
|
+
try {
|
|
286
|
+
await runMeasuredEffect(runtime, effect, runMode);
|
|
287
|
+
if (expected === "failure") throw new Error("Expected measured effect to fail");
|
|
288
|
+
} catch (error) {
|
|
289
|
+
if (expected === "success") throw error;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
function makeRuntimeCase(variant, chainDepth) {
|
|
293
|
+
const schedulerConfig = variant === "wide-scheduler" ? {
|
|
294
|
+
laneMode: "single",
|
|
295
|
+
initialCapacity: Math.max(65536, chainDepth * 8),
|
|
296
|
+
maxCapacity: Math.max(65536, chainDepth * 8),
|
|
297
|
+
flushBudget: 16384
|
|
298
|
+
} : {
|
|
299
|
+
laneMode: "fair",
|
|
300
|
+
initialCapacity: 8192,
|
|
301
|
+
maxCapacity: 8192,
|
|
302
|
+
flushBudget: 2048
|
|
303
|
+
};
|
|
304
|
+
const scheduler = variant === "wide-scheduler" ? new Scheduler(schedulerConfig) : void 0;
|
|
305
|
+
const recorder = variant === "recorder" ? makeRuntimeRecorder({ maxEvents: 1e4 }) : void 0;
|
|
306
|
+
const hooks = recorder?.hooks ?? (variant === "active-hooks" ? new EventBus() : void 0);
|
|
307
|
+
const runtime = Runtime.makeWithEngine(hooks ? {} : {}, "ts", {
|
|
308
|
+
hooks,
|
|
309
|
+
...scheduler ? { scheduler } : {},
|
|
310
|
+
inferLane: false
|
|
311
|
+
});
|
|
312
|
+
return {
|
|
313
|
+
runtime,
|
|
314
|
+
label: labelForVariant(variant),
|
|
315
|
+
scheduler: schedulerConfig,
|
|
316
|
+
recorder
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
function labelForVariant(variant) {
|
|
320
|
+
switch (variant) {
|
|
321
|
+
case "default":
|
|
322
|
+
return "default runtime with native top-level fast path";
|
|
323
|
+
case "fiber-only":
|
|
324
|
+
return "forced fiber execution for every measured effect";
|
|
325
|
+
case "active-hooks":
|
|
326
|
+
return "runtime with active EventBus hooks";
|
|
327
|
+
case "recorder":
|
|
328
|
+
return "runtime with bounded flight recorder hooks";
|
|
329
|
+
case "wide-scheduler":
|
|
330
|
+
return "runtime with larger single-lane scheduler queues";
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
async function runMeasuredEffect(runtime, effect, runMode) {
|
|
334
|
+
if (runMode === "auto") return runtime.toPromise(effect);
|
|
335
|
+
return new Promise((resolve2, reject) => {
|
|
336
|
+
const fiber = runtime.fork(effect);
|
|
337
|
+
fiber.join((exit) => {
|
|
338
|
+
if (exit._tag === "Success") resolve2(exit.value);
|
|
339
|
+
else reject(exit.cause);
|
|
340
|
+
});
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
function asyncFailProgram() {
|
|
344
|
+
return { _tag: "Fail", error: "expected-profile-failure" };
|
|
345
|
+
}
|
|
346
|
+
function makeFlatMapChain(depth) {
|
|
347
|
+
let effect = asyncSucceed(0);
|
|
348
|
+
for (let i = 0; i < depth; i++) {
|
|
349
|
+
effect = asyncFlatMap(effect, (n) => asyncSucceed(n + 1));
|
|
350
|
+
}
|
|
351
|
+
return effect;
|
|
352
|
+
}
|
|
353
|
+
function makeFiberRefProgram(depth) {
|
|
354
|
+
const ref = makeFiberRef(0);
|
|
355
|
+
let effect = ref.set(0);
|
|
356
|
+
for (let i = 0; i < depth; i++) {
|
|
357
|
+
effect = asyncFlatMap(effect, () => ref.update((n) => n + 1));
|
|
358
|
+
}
|
|
359
|
+
return asyncFlatMap(effect, () => ref.get());
|
|
360
|
+
}
|
|
361
|
+
function positiveInt(value, fallback) {
|
|
362
|
+
if (value === void 0 || !Number.isFinite(value)) return fallback;
|
|
363
|
+
return Math.max(1, Math.floor(value));
|
|
364
|
+
}
|
|
365
|
+
function startedFibers(runtime) {
|
|
366
|
+
return runtime.stats().data.startedFibers ?? 0;
|
|
367
|
+
}
|
|
368
|
+
function round3(n) {
|
|
369
|
+
return Math.round(n * 1e3) / 1e3;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// src/perf/runtimeDiagnostics.ts
|
|
373
|
+
function diagnoseRuntimeProfile(report) {
|
|
374
|
+
const diagnostics = report.results.map(toDiagnostic);
|
|
375
|
+
const sortedByNs = [...diagnostics].sort((a, b) => b.nsPerOperation - a.nsPerOperation);
|
|
376
|
+
const totalFibersStarted = diagnostics.reduce((sum2, item) => sum2 + item.fibersStarted, 0);
|
|
377
|
+
const totalMeasuredUnits = report.results.reduce((sum2, item) => sum2 + item.units, 0);
|
|
378
|
+
const notes = makeNotes(report, sortedByNs, totalFibersStarted, totalMeasuredUnits);
|
|
379
|
+
return Object.freeze({
|
|
380
|
+
variant: report.variant,
|
|
381
|
+
slowest: sortedByNs[0],
|
|
382
|
+
fastest: sortedByNs[sortedByNs.length - 1],
|
|
383
|
+
hotPrimitives: Object.freeze(sortedByNs.slice(0, 3)),
|
|
384
|
+
totalFibersStarted,
|
|
385
|
+
totalMeasuredUnits,
|
|
386
|
+
averageFibersPerThousandOps: round4(totalFibersStarted / Math.max(totalMeasuredUnits, 1) * 1e3),
|
|
387
|
+
hooksActive: report.hooksActive,
|
|
388
|
+
recorderEvents: report.recorderEvents,
|
|
389
|
+
notes: Object.freeze(notes)
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
function toDiagnostic(result) {
|
|
393
|
+
return Object.freeze({
|
|
394
|
+
name: result.name,
|
|
395
|
+
operationsPerSecond: result.operationsPerSecond,
|
|
396
|
+
nsPerOperation: result.nsPerOperation,
|
|
397
|
+
fibersStarted: result.fibersStarted,
|
|
398
|
+
fibersPerThousandOps: result.fibersPerThousandOps
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
function makeNotes(report, sortedByNs, totalFibersStarted, totalMeasuredUnits) {
|
|
402
|
+
const notes = [];
|
|
403
|
+
const topLevelPure = report.results.filter(
|
|
404
|
+
(item) => item.name === "async-succeed/top-level" || item.name === "async-fail/top-level" || item.name === "async-sync/top-level" || item.name === "flatMap-chain"
|
|
405
|
+
);
|
|
406
|
+
const pureStartedFibers = topLevelPure.reduce((sum2, item) => sum2 + item.fibersStarted, 0);
|
|
407
|
+
if (pureStartedFibers === 0 && report.variant === "default") {
|
|
408
|
+
notes.push("Native top-level fast path avoided root fibers for pure/sync runtime primitives.");
|
|
409
|
+
}
|
|
410
|
+
if (report.hooksActive) {
|
|
411
|
+
notes.push("Hooks are active, so top-level pure effects keep normal fiber/event semantics.");
|
|
412
|
+
}
|
|
413
|
+
if (report.recorderEvents && report.recorderEvents > 0) {
|
|
414
|
+
notes.push(`Runtime recorder retained ${report.recorderEvents} events during the profile.`);
|
|
415
|
+
}
|
|
416
|
+
if (totalFibersStarted > totalMeasuredUnits * 0.25) {
|
|
417
|
+
notes.push("Fiber allocation pressure is visible; compare with the fiber-only baseline to isolate scheduling overhead.");
|
|
418
|
+
}
|
|
419
|
+
if (sortedByNs[0]) {
|
|
420
|
+
notes.push(`${sortedByNs[0].name} is the hottest sampled primitive by ns/op.`);
|
|
421
|
+
}
|
|
422
|
+
return notes;
|
|
423
|
+
}
|
|
424
|
+
function round4(n) {
|
|
425
|
+
return Math.round(n * 1e3) / 1e3;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// src/perf/runtimeAb.ts
|
|
429
|
+
async function profileRuntimeAb(options = {}) {
|
|
430
|
+
const baselineVariant = options.baseline ?? "fiber-only";
|
|
431
|
+
const candidateVariant = options.candidate ?? "default";
|
|
432
|
+
const thresholds = normalizeThresholds(options.thresholds);
|
|
433
|
+
const before = captureMemorySnapshot({ forceGc: options.forceGc });
|
|
434
|
+
const baseline = await profileRuntimePrimitives({
|
|
435
|
+
...options.runtime ?? {},
|
|
436
|
+
variant: baselineVariant
|
|
437
|
+
});
|
|
438
|
+
const candidate = await profileRuntimePrimitives({
|
|
439
|
+
...options.runtime ?? {},
|
|
440
|
+
variant: candidateVariant
|
|
441
|
+
});
|
|
442
|
+
const after = captureMemorySnapshot({ forceGc: options.forceGc });
|
|
443
|
+
const comparisons = compareRuntimeProfiles(baseline, candidate, thresholds);
|
|
444
|
+
const budgetViolations = comparisons.filter((comparison) => comparison.deltaPercent < -thresholds.maxRegressionPercent).map((comparison) => `${comparison.primitive} regressed ${Math.abs(comparison.deltaPercent).toFixed(1)}%`);
|
|
445
|
+
return Object.freeze({
|
|
446
|
+
baselineVariant,
|
|
447
|
+
candidateVariant,
|
|
448
|
+
baseline,
|
|
449
|
+
candidate,
|
|
450
|
+
comparisons,
|
|
451
|
+
diagnostics: Object.freeze({
|
|
452
|
+
baseline: diagnoseRuntimeProfile(baseline),
|
|
453
|
+
candidate: diagnoseRuntimeProfile(candidate)
|
|
454
|
+
}),
|
|
455
|
+
memory: Object.freeze({
|
|
456
|
+
delta: diffMemorySnapshots(before, after)
|
|
457
|
+
}),
|
|
458
|
+
passedBudget: budgetViolations.length === 0,
|
|
459
|
+
budgetViolations: Object.freeze(budgetViolations)
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
function compareRuntimeProfiles(baseline, candidate, thresholds = normalizeThresholds()) {
|
|
463
|
+
const candidateByName = new Map(candidate.results.map((result) => [result.name, result]));
|
|
464
|
+
return Object.freeze(baseline.results.flatMap((base) => {
|
|
465
|
+
const next = candidateByName.get(base.name);
|
|
466
|
+
if (!next) return [];
|
|
467
|
+
const deltaPercent = percentDelta(next.operationsPerSecond, base.operationsPerSecond);
|
|
468
|
+
const verdict = deltaPercent >= thresholds.minSignificantDeltaPercent ? "improved" : deltaPercent <= -thresholds.minSignificantDeltaPercent ? "regressed" : "same";
|
|
469
|
+
return [Object.freeze({
|
|
470
|
+
primitive: base.name,
|
|
471
|
+
baselineOpsPerSecond: base.operationsPerSecond,
|
|
472
|
+
candidateOpsPerSecond: next.operationsPerSecond,
|
|
473
|
+
deltaPercent: round5(deltaPercent),
|
|
474
|
+
baselineNsPerOperation: base.nsPerOperation,
|
|
475
|
+
candidateNsPerOperation: next.nsPerOperation,
|
|
476
|
+
fibersStartedDelta: next.fibersStarted - base.fibersStarted,
|
|
477
|
+
verdict
|
|
478
|
+
})];
|
|
479
|
+
}));
|
|
480
|
+
}
|
|
481
|
+
function formatRuntimeAbReport(report) {
|
|
482
|
+
const lines = [];
|
|
483
|
+
lines.push("Brass Runtime A/B Performance Lab");
|
|
484
|
+
lines.push(`baseline=${report.baselineVariant} candidate=${report.candidateVariant} budget=${report.passedBudget ? "pass" : "fail"}`);
|
|
485
|
+
lines.push("");
|
|
486
|
+
lines.push("Comparisons");
|
|
487
|
+
for (const comparison of report.comparisons) {
|
|
488
|
+
const sign = comparison.deltaPercent >= 0 ? "+" : "";
|
|
489
|
+
lines.push(`- ${comparison.primitive}: ${sign}${comparison.deltaPercent}% (${formatNumber(comparison.baselineOpsPerSecond)} -> ${formatNumber(comparison.candidateOpsPerSecond)} ops/s) ${comparison.verdict}`);
|
|
490
|
+
}
|
|
491
|
+
lines.push("");
|
|
492
|
+
lines.push(`Memory: heapDelta=${report.memory.delta.heapUsedMb}MB rssDelta=${report.memory.delta.rssMb}MB`);
|
|
493
|
+
lines.push("");
|
|
494
|
+
lines.push("Candidate diagnostics");
|
|
495
|
+
for (const note of report.diagnostics.candidate.notes) {
|
|
496
|
+
lines.push(`- ${note}`);
|
|
497
|
+
}
|
|
498
|
+
if (report.budgetViolations.length > 0) {
|
|
499
|
+
lines.push("");
|
|
500
|
+
lines.push("Budget violations");
|
|
501
|
+
for (const violation of report.budgetViolations) lines.push(`- ${violation}`);
|
|
502
|
+
}
|
|
503
|
+
return lines.join("\n");
|
|
504
|
+
}
|
|
505
|
+
function normalizeThresholds(thresholds = {}) {
|
|
506
|
+
return {
|
|
507
|
+
maxRegressionPercent: positiveNumber(thresholds.maxRegressionPercent, 50),
|
|
508
|
+
minSignificantDeltaPercent: positiveNumber(thresholds.minSignificantDeltaPercent, 5)
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
function percentDelta(candidate, baseline) {
|
|
512
|
+
if (baseline <= 0) return 0;
|
|
513
|
+
return (candidate - baseline) / baseline * 100;
|
|
514
|
+
}
|
|
515
|
+
function positiveNumber(value, fallback) {
|
|
516
|
+
if (value === void 0 || !Number.isFinite(value)) return fallback;
|
|
517
|
+
return Math.max(0, value);
|
|
518
|
+
}
|
|
519
|
+
function formatNumber(value) {
|
|
520
|
+
return value >= 1e3 ? value.toLocaleString("en-US", { maximumFractionDigits: 0 }) : value.toFixed(3);
|
|
521
|
+
}
|
|
522
|
+
function round5(n) {
|
|
523
|
+
return Math.round(n * 1e3) / 1e3;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// src/perf/runtimeSoak.ts
|
|
527
|
+
async function profileRuntimeSoak(options = {}) {
|
|
528
|
+
const rounds = positiveInt2(options.rounds, 5);
|
|
529
|
+
const variant = options.variant ?? options.runtime?.variant ?? "default";
|
|
530
|
+
const before = captureMemorySnapshot({ forceGc: options.forceGc });
|
|
531
|
+
const results = [];
|
|
532
|
+
for (let i = 0; i < rounds; i++) {
|
|
533
|
+
const roundBefore = captureMemorySnapshot({ forceGc: options.forceGc });
|
|
534
|
+
const report = await profileRuntimePrimitives({
|
|
535
|
+
...options.runtime ?? {},
|
|
536
|
+
variant
|
|
537
|
+
});
|
|
538
|
+
const roundAfter = captureMemorySnapshot({ forceGc: options.forceGc });
|
|
539
|
+
const delta2 = diffMemorySnapshots(roundBefore, roundAfter);
|
|
540
|
+
results.push(Object.freeze({
|
|
541
|
+
round: i + 1,
|
|
542
|
+
report,
|
|
543
|
+
diagnostics: diagnoseRuntimeProfile(report),
|
|
544
|
+
heapDeltaMb: delta2.heapUsedMb,
|
|
545
|
+
rssDeltaMb: delta2.rssMb
|
|
546
|
+
}));
|
|
547
|
+
}
|
|
548
|
+
const after = captureMemorySnapshot({ forceGc: options.forceGc });
|
|
549
|
+
return Object.freeze({
|
|
550
|
+
variant,
|
|
551
|
+
rounds: Object.freeze(results),
|
|
552
|
+
memory: Object.freeze({
|
|
553
|
+
delta: diffMemorySnapshots(before, after)
|
|
554
|
+
}),
|
|
555
|
+
throughputTrendPercent: round6(throughputTrend(results)),
|
|
556
|
+
heapTrendMb: round6(results.reduce((sum2, item) => sum2 + item.heapDeltaMb, 0))
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
function formatRuntimeSoakReport(report) {
|
|
560
|
+
const lines = [];
|
|
561
|
+
lines.push("Brass Runtime Soak Profile");
|
|
562
|
+
lines.push(`variant=${report.variant} rounds=${report.rounds.length} throughputTrend=${signed(report.throughputTrendPercent)}% heapTrend=${report.heapTrendMb}MB`);
|
|
563
|
+
lines.push("");
|
|
564
|
+
for (const round10 of report.rounds) {
|
|
565
|
+
const aggregateOps = aggregateOpsPerSecond(round10.report);
|
|
566
|
+
lines.push(`- round ${round10.round}: aggregate=${formatNumber2(aggregateOps)} ops/s heapDelta=${round10.heapDeltaMb}MB rssDelta=${round10.rssDeltaMb}MB hot=${round10.diagnostics.slowest.name}`);
|
|
567
|
+
}
|
|
568
|
+
lines.push("");
|
|
569
|
+
lines.push(`Total memory: heapDelta=${report.memory.delta.heapUsedMb}MB rssDelta=${report.memory.delta.rssMb}MB`);
|
|
570
|
+
return lines.join("\n");
|
|
571
|
+
}
|
|
572
|
+
function throughputTrend(rounds) {
|
|
573
|
+
if (rounds.length < 2) return 0;
|
|
574
|
+
const first = aggregateOpsPerSecond(rounds[0].report);
|
|
575
|
+
const last = aggregateOpsPerSecond(rounds[rounds.length - 1].report);
|
|
576
|
+
if (first <= 0) return 0;
|
|
577
|
+
return (last - first) / first * 100;
|
|
578
|
+
}
|
|
579
|
+
function aggregateOpsPerSecond(report) {
|
|
580
|
+
const totalUnits = report.results.reduce((sum2, item) => sum2 + item.units, 0);
|
|
581
|
+
const totalDurationMs = report.results.reduce((sum2, item) => sum2 + item.durationMs, 0);
|
|
582
|
+
return totalUnits / Math.max(totalDurationMs / 1e3, 1e-3);
|
|
583
|
+
}
|
|
584
|
+
function positiveInt2(value, fallback) {
|
|
585
|
+
if (value === void 0 || !Number.isFinite(value)) return fallback;
|
|
586
|
+
return Math.max(1, Math.floor(value));
|
|
587
|
+
}
|
|
588
|
+
function formatNumber2(value) {
|
|
589
|
+
return value >= 1e3 ? value.toLocaleString("en-US", { maximumFractionDigits: 0 }) : value.toFixed(3);
|
|
590
|
+
}
|
|
591
|
+
function signed(value) {
|
|
592
|
+
return value >= 0 ? `+${value}` : String(value);
|
|
593
|
+
}
|
|
594
|
+
function round6(n) {
|
|
595
|
+
return Math.round(n * 1e3) / 1e3;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// src/perf/httpProfiler.ts
|
|
599
|
+
import {
|
|
600
|
+
Agent as HttpAgent,
|
|
601
|
+
createServer,
|
|
602
|
+
request as httpRequest
|
|
603
|
+
} from "http";
|
|
604
|
+
var HTTP_PROFILE_VARIANTS = [
|
|
605
|
+
"node-http-text",
|
|
606
|
+
"wire-raw",
|
|
607
|
+
"default-minimal-json",
|
|
608
|
+
"default-balanced-no-adaptive-json",
|
|
609
|
+
"default-balanced-json",
|
|
610
|
+
"default-json",
|
|
611
|
+
"default-json-observed"
|
|
612
|
+
];
|
|
613
|
+
async function profileHttpLayers(options = {}) {
|
|
614
|
+
const calls = positiveInt3(options.calls, 1e3);
|
|
615
|
+
const concurrency = positiveInt3(options.concurrency, 64);
|
|
616
|
+
const delayMs = nonNegativeInt(options.delayMs, 1);
|
|
617
|
+
const timeoutMs = positiveInt3(options.timeoutMs, 3e4);
|
|
618
|
+
const warmupCalls = nonNegativeInt(options.warmupCalls, Math.min(200, Math.floor(calls / 10)));
|
|
619
|
+
const statsSampleMs = positiveInt3(options.statsSampleMs, 10);
|
|
620
|
+
const variants = normalizeVariants(options.variants);
|
|
621
|
+
const results = [];
|
|
622
|
+
for (const variant of variants) {
|
|
623
|
+
const scenario = {
|
|
624
|
+
variant,
|
|
625
|
+
label: labelForVariant2(variant),
|
|
626
|
+
calls,
|
|
627
|
+
warmupCalls,
|
|
628
|
+
concurrency,
|
|
629
|
+
delayMs,
|
|
630
|
+
timeoutMs,
|
|
631
|
+
statsSampleMs,
|
|
632
|
+
forceGc: options.forceGc ?? false
|
|
633
|
+
};
|
|
634
|
+
const result = await options.recorder?.measureAsync(
|
|
635
|
+
`http.profile.${variant}`,
|
|
636
|
+
() => runScenario(scenario),
|
|
637
|
+
{ calls, concurrency, delayMs },
|
|
638
|
+
{ variant }
|
|
639
|
+
) ?? await runScenario(scenario);
|
|
640
|
+
options.recorder?.gauge("http.profile.throughput", result.httpPerSec, "http/s", { variant });
|
|
641
|
+
options.recorder?.gauge("http.profile.heap-delta", result.memory.delta.heapUsedMb, "MB", { variant });
|
|
642
|
+
results.push(result);
|
|
643
|
+
}
|
|
644
|
+
return Object.freeze({
|
|
645
|
+
calls,
|
|
646
|
+
concurrency,
|
|
647
|
+
delayMs,
|
|
648
|
+
timeoutMs,
|
|
649
|
+
warmupCalls,
|
|
650
|
+
variants,
|
|
651
|
+
results: Object.freeze(results)
|
|
652
|
+
});
|
|
653
|
+
}
|
|
654
|
+
async function runScenario(scenario) {
|
|
655
|
+
const server = await startDummyServer(scenario.delayMs);
|
|
656
|
+
const observability = scenario.variant === "default-json-observed" ? makeObservability({
|
|
657
|
+
logs: false,
|
|
658
|
+
traces: { maxFinishedSpans: 1e4 },
|
|
659
|
+
autoStart: false
|
|
660
|
+
}) : void 0;
|
|
661
|
+
const runner = makeScenarioRunner(scenario, server, observability);
|
|
662
|
+
let warmupDurationMs = 0;
|
|
663
|
+
try {
|
|
664
|
+
if (scenario.warmupCalls > 0) {
|
|
665
|
+
const warmup = await runHttpLoad(
|
|
666
|
+
{
|
|
667
|
+
...scenario,
|
|
668
|
+
calls: scenario.warmupCalls,
|
|
669
|
+
warmupCalls: 0,
|
|
670
|
+
idOffset: scenario.calls + 1e6
|
|
671
|
+
},
|
|
672
|
+
server,
|
|
673
|
+
runner.runOne,
|
|
674
|
+
runner.readClientStats,
|
|
675
|
+
observability
|
|
676
|
+
);
|
|
677
|
+
warmupDurationMs = warmup.durationMs;
|
|
678
|
+
flushObservability(observability);
|
|
679
|
+
dropFinishedSpans(observability);
|
|
680
|
+
await runner.reset?.();
|
|
681
|
+
server.resetStats();
|
|
682
|
+
}
|
|
683
|
+
const result = await runHttpLoad(
|
|
684
|
+
scenario,
|
|
685
|
+
server,
|
|
686
|
+
runner.runOne,
|
|
687
|
+
runner.readClientStats,
|
|
688
|
+
observability
|
|
689
|
+
);
|
|
690
|
+
return Object.freeze({ ...result, warmupDurationMs: round7(warmupDurationMs) });
|
|
691
|
+
} finally {
|
|
692
|
+
await runner.cleanup?.();
|
|
693
|
+
await observability?.shutdown();
|
|
694
|
+
await server.close();
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
function startDummyServer(delayMs) {
|
|
698
|
+
return new Promise((resolve2, reject) => {
|
|
699
|
+
let requests = 0;
|
|
700
|
+
let inFlight = 0;
|
|
701
|
+
let maxInFlight = 0;
|
|
702
|
+
const server = createServer((req, res) => {
|
|
703
|
+
const id = ++requests;
|
|
704
|
+
inFlight++;
|
|
705
|
+
if (inFlight > maxInFlight) maxInFlight = inFlight;
|
|
706
|
+
const finish = () => {
|
|
707
|
+
const payload = {
|
|
708
|
+
ok: true,
|
|
709
|
+
id,
|
|
710
|
+
delayMs,
|
|
711
|
+
path: req.url ?? "/"
|
|
712
|
+
};
|
|
713
|
+
const body = JSON.stringify(payload);
|
|
714
|
+
res.writeHead(200, {
|
|
715
|
+
"content-type": "application/json",
|
|
716
|
+
"content-length": Buffer.byteLength(body),
|
|
717
|
+
"cache-control": "no-store"
|
|
718
|
+
});
|
|
719
|
+
res.end(body, () => {
|
|
720
|
+
inFlight--;
|
|
721
|
+
});
|
|
722
|
+
};
|
|
723
|
+
if (delayMs > 0) setTimeout(finish, delayMs);
|
|
724
|
+
else finish();
|
|
725
|
+
});
|
|
726
|
+
server.keepAliveTimeout = 65e3;
|
|
727
|
+
server.headersTimeout = 66e3;
|
|
728
|
+
server.maxRequestsPerSocket = 0;
|
|
729
|
+
server.once("error", reject);
|
|
730
|
+
server.listen(0, "127.0.0.1", () => {
|
|
731
|
+
server.off("error", reject);
|
|
732
|
+
const address = server.address();
|
|
733
|
+
resolve2({
|
|
734
|
+
baseUrl: `http://127.0.0.1:${address.port}`,
|
|
735
|
+
stats: () => ({ requests, maxInFlight }),
|
|
736
|
+
resetStats: () => {
|
|
737
|
+
requests = 0;
|
|
738
|
+
maxInFlight = inFlight;
|
|
739
|
+
},
|
|
740
|
+
close: () => closeServer(server)
|
|
741
|
+
});
|
|
742
|
+
});
|
|
743
|
+
});
|
|
744
|
+
}
|
|
745
|
+
function closeServer(server) {
|
|
746
|
+
return new Promise((resolve2, reject) => {
|
|
747
|
+
server.close((err) => {
|
|
748
|
+
if (err) reject(err);
|
|
749
|
+
else resolve2();
|
|
750
|
+
});
|
|
751
|
+
});
|
|
752
|
+
}
|
|
753
|
+
function makeScenarioRunner(scenario, server, observability) {
|
|
754
|
+
switch (scenario.variant) {
|
|
755
|
+
case "node-http-text":
|
|
756
|
+
return makeNodeHttpRunner(server, scenario);
|
|
757
|
+
case "wire-raw":
|
|
758
|
+
return makeWireRawRunner(server, scenario);
|
|
759
|
+
case "default-minimal-json":
|
|
760
|
+
return makeDefaultJsonRunner(server, scenario, "minimal");
|
|
761
|
+
case "default-balanced-no-adaptive-json":
|
|
762
|
+
return makeDefaultJsonRunner(server, scenario, "balanced", true);
|
|
763
|
+
case "default-balanced-json":
|
|
764
|
+
return makeDefaultJsonRunner(server, scenario, "balanced");
|
|
765
|
+
case "default-json":
|
|
766
|
+
return makeDefaultJsonRunner(server, scenario, "default");
|
|
767
|
+
case "default-json-observed":
|
|
768
|
+
return makeDefaultJsonRunner(server, scenario, "default", false, observability);
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
function makeNodeHttpRunner(server, scenario) {
|
|
772
|
+
const agent = new HttpAgent({
|
|
773
|
+
keepAlive: true,
|
|
774
|
+
maxSockets: scenario.concurrency,
|
|
775
|
+
maxFreeSockets: scenario.concurrency
|
|
776
|
+
});
|
|
777
|
+
return {
|
|
778
|
+
runOne: (id, cb) => {
|
|
779
|
+
let done = false;
|
|
780
|
+
const finish = (result) => {
|
|
781
|
+
if (done) return;
|
|
782
|
+
done = true;
|
|
783
|
+
cb(result);
|
|
784
|
+
};
|
|
785
|
+
const url = `${server.baseUrl}/todos/${id % 100}?i=${id}`;
|
|
786
|
+
const req = httpRequest(url, { agent, method: "GET" }, (res) => {
|
|
787
|
+
let bytes = 0;
|
|
788
|
+
res.on("data", (chunk) => {
|
|
789
|
+
bytes += chunk.length;
|
|
790
|
+
});
|
|
791
|
+
res.on("end", () => {
|
|
792
|
+
finish({ ok: res.statusCode === 200 && bytes > 0 });
|
|
793
|
+
});
|
|
794
|
+
});
|
|
795
|
+
req.setTimeout(scenario.timeoutMs, () => {
|
|
796
|
+
req.destroy(new Error(`node:http request timed out after ${scenario.timeoutMs}ms`));
|
|
797
|
+
});
|
|
798
|
+
req.on("error", (error) => finish({ ok: false, error }));
|
|
799
|
+
req.end();
|
|
800
|
+
},
|
|
801
|
+
cleanup: () => agent.destroy()
|
|
802
|
+
};
|
|
803
|
+
}
|
|
804
|
+
function makeWireRawRunner(server, scenario) {
|
|
805
|
+
const rt = makeProfileRuntime(scenario.concurrency);
|
|
806
|
+
const client = makeWireClient(server.baseUrl, scenario);
|
|
807
|
+
return {
|
|
808
|
+
runOne: (id, cb) => {
|
|
809
|
+
const effect = client({ method: "GET", url: `/todos/${id % 100}?i=${id}` });
|
|
810
|
+
runRuntimeEffect(rt, effect, cb, (res) => res.status === 200 && res.bodyText.length > 0);
|
|
811
|
+
},
|
|
812
|
+
readClientStats: () => snapshotWireStats(client.stats()),
|
|
813
|
+
cleanup: async () => {
|
|
814
|
+
client.shutdown?.();
|
|
815
|
+
client.destroy?.();
|
|
816
|
+
await rt.shutdown();
|
|
817
|
+
}
|
|
818
|
+
};
|
|
819
|
+
}
|
|
820
|
+
function makeDefaultJsonRunner(server, scenario, preset, disableAdaptiveLimiter = false, observability) {
|
|
821
|
+
const rt = makeProfileRuntime(scenario.concurrency, observability);
|
|
822
|
+
const client = preset === "minimal" ? makeMinimalClient(server.baseUrl, scenario) : makeDefaultClient(server.baseUrl, scenario, preset, disableAdaptiveLimiter, observability);
|
|
823
|
+
return {
|
|
824
|
+
runOne: (id, cb) => {
|
|
825
|
+
const effect = client.getJson(`/todos/${id % 100}?i=${id}`);
|
|
826
|
+
runRuntimeEffect(
|
|
827
|
+
rt,
|
|
828
|
+
effect,
|
|
829
|
+
cb,
|
|
830
|
+
(res) => res.status === 200 && res.body.ok === true
|
|
831
|
+
);
|
|
832
|
+
},
|
|
833
|
+
readClientStats: () => snapshotLifecycleStats(client.stats()),
|
|
834
|
+
reset: () => client.cache.clear(),
|
|
835
|
+
cleanup: async () => {
|
|
836
|
+
await rt.toPromise(client.shutdown());
|
|
837
|
+
await rt.shutdown();
|
|
838
|
+
}
|
|
839
|
+
};
|
|
840
|
+
}
|
|
841
|
+
function makeProfileRuntime(concurrency, observability) {
|
|
842
|
+
const capacity = Math.max(65536, concurrency * 8);
|
|
843
|
+
return Runtime.makeWithEngine(observability?.env ?? {}, "ts", {
|
|
844
|
+
hooks: observability?.hooks,
|
|
845
|
+
scheduler: new Scheduler({
|
|
846
|
+
laneMode: "single",
|
|
847
|
+
initialCapacity: capacity,
|
|
848
|
+
maxCapacity: capacity,
|
|
849
|
+
flushBudget: 8192
|
|
850
|
+
}),
|
|
851
|
+
inferLane: false
|
|
852
|
+
}).withLane("perf/http");
|
|
853
|
+
}
|
|
854
|
+
function baseHttpConfig(baseUrl, scenario) {
|
|
855
|
+
return {
|
|
856
|
+
baseUrl,
|
|
857
|
+
timeoutMs: scenario.timeoutMs,
|
|
858
|
+
pool: {
|
|
859
|
+
concurrency: scenario.concurrency,
|
|
860
|
+
maxQueue: scenario.concurrency,
|
|
861
|
+
queueTimeoutMs: scenario.timeoutMs,
|
|
862
|
+
key: "origin"
|
|
863
|
+
}
|
|
864
|
+
};
|
|
865
|
+
}
|
|
866
|
+
function makeWireClient(baseUrl, scenario) {
|
|
867
|
+
return makeHttp(baseHttpConfig(baseUrl, scenario));
|
|
868
|
+
}
|
|
869
|
+
function makeMinimalClient(baseUrl, scenario) {
|
|
870
|
+
return makeDefaultHttpClient({
|
|
871
|
+
preset: "minimal",
|
|
872
|
+
compression: false,
|
|
873
|
+
...baseHttpConfig(baseUrl, scenario)
|
|
874
|
+
});
|
|
875
|
+
}
|
|
876
|
+
function makeDefaultClient(baseUrl, scenario, preset, disableAdaptiveLimiter, observability) {
|
|
877
|
+
return makeDefaultHttpClient({
|
|
878
|
+
preset,
|
|
879
|
+
compression: false,
|
|
880
|
+
...baseHttpConfig(baseUrl, scenario),
|
|
881
|
+
...disableAdaptiveLimiter ? { adaptiveLimiter: false } : {},
|
|
882
|
+
...observability ? {
|
|
883
|
+
middleware: [
|
|
884
|
+
withHttpObservability({
|
|
885
|
+
metrics: observability.metrics,
|
|
886
|
+
logs: false,
|
|
887
|
+
spans: {},
|
|
888
|
+
injectTraceHeaders: true,
|
|
889
|
+
route: "/todos/:id"
|
|
890
|
+
})
|
|
891
|
+
]
|
|
892
|
+
} : {}
|
|
893
|
+
});
|
|
894
|
+
}
|
|
895
|
+
function runRuntimeEffect(rt, effect, cb, validate) {
|
|
896
|
+
rt.unsafeRunAsync(effect, (exit) => {
|
|
897
|
+
if (exit._tag === "Success") {
|
|
898
|
+
cb({ ok: validate(exit.value) });
|
|
899
|
+
} else {
|
|
900
|
+
cb({ ok: false, error: exit });
|
|
901
|
+
}
|
|
902
|
+
});
|
|
903
|
+
}
|
|
904
|
+
function runHttpLoad(scenario, server, runOne, readClientStats, observability) {
|
|
905
|
+
return new Promise((resolve2, reject) => {
|
|
906
|
+
let next = 0;
|
|
907
|
+
let inFlight = 0;
|
|
908
|
+
let maxClientInFlight = 0;
|
|
909
|
+
let completed = 0;
|
|
910
|
+
let successCount = 0;
|
|
911
|
+
let errorCount = 0;
|
|
912
|
+
let firstError;
|
|
913
|
+
let maxWireInFlight = 0;
|
|
914
|
+
let maxPoolRunning = 0;
|
|
915
|
+
let maxPoolQueued = 0;
|
|
916
|
+
let maxLifecycleQueueDepth = 0;
|
|
917
|
+
let sawPoolStats = false;
|
|
918
|
+
let minAdaptiveLimit = Number.POSITIVE_INFINITY;
|
|
919
|
+
let maxAdaptiveLimit = 0;
|
|
920
|
+
let maxAdaptiveInFlight = 0;
|
|
921
|
+
let maxAdaptiveQueueDepth = 0;
|
|
922
|
+
let sawAdaptiveStats = false;
|
|
923
|
+
let lastClientStats;
|
|
924
|
+
const initialClientStats = readClientStats?.();
|
|
925
|
+
let latencies = new Array(scenario.calls);
|
|
926
|
+
const memoryBefore = captureMemorySnapshot({ forceGc: scenario.forceGc });
|
|
927
|
+
const startedAt = performance.now();
|
|
928
|
+
const sampleClientStats = () => {
|
|
929
|
+
const stats = readClientStats?.();
|
|
930
|
+
if (!stats) return;
|
|
931
|
+
lastClientStats = stats;
|
|
932
|
+
maxWireInFlight = Math.max(maxWireInFlight, stats.wireInFlight ?? 0);
|
|
933
|
+
if (stats.poolRunning !== void 0 || stats.poolQueued !== void 0) {
|
|
934
|
+
sawPoolStats = true;
|
|
935
|
+
maxPoolRunning = Math.max(maxPoolRunning, stats.poolRunning ?? 0);
|
|
936
|
+
maxPoolQueued = Math.max(maxPoolQueued, stats.poolQueued ?? 0);
|
|
937
|
+
}
|
|
938
|
+
if (stats.adaptiveLimit !== void 0) {
|
|
939
|
+
sawAdaptiveStats = true;
|
|
940
|
+
minAdaptiveLimit = Math.min(minAdaptiveLimit, stats.adaptiveLimit);
|
|
941
|
+
maxAdaptiveLimit = Math.max(maxAdaptiveLimit, stats.adaptiveLimit);
|
|
942
|
+
maxAdaptiveInFlight = Math.max(maxAdaptiveInFlight, stats.adaptiveInFlight ?? 0);
|
|
943
|
+
maxAdaptiveQueueDepth = Math.max(maxAdaptiveQueueDepth, stats.adaptiveQueueDepth ?? 0);
|
|
944
|
+
}
|
|
945
|
+
maxLifecycleQueueDepth = Math.max(maxLifecycleQueueDepth, stats.lifecycleQueueDepth ?? 0);
|
|
946
|
+
};
|
|
947
|
+
const sampleTimer = readClientStats ? setInterval(sampleClientStats, scenario.statsSampleMs) : void 0;
|
|
948
|
+
const finish = () => {
|
|
949
|
+
if (sampleTimer) clearInterval(sampleTimer);
|
|
950
|
+
sampleClientStats();
|
|
951
|
+
const durationMs = performance.now() - startedAt;
|
|
952
|
+
const observabilityFlushMs = flushObservability(observability);
|
|
953
|
+
const latencyPercentiles = percentiles(latencies);
|
|
954
|
+
latencies = [];
|
|
955
|
+
const memoryAfter = captureMemorySnapshot({ forceGc: scenario.forceGc });
|
|
956
|
+
const serverStats = server.stats();
|
|
957
|
+
const tracerStats = observability?.tracer.stats();
|
|
958
|
+
resolve2(Object.freeze({
|
|
959
|
+
variant: scenario.variant,
|
|
960
|
+
label: scenario.label,
|
|
961
|
+
calls: scenario.calls,
|
|
962
|
+
warmupCalls: scenario.warmupCalls,
|
|
963
|
+
concurrency: scenario.concurrency,
|
|
964
|
+
delayMs: scenario.delayMs,
|
|
965
|
+
timeoutMs: scenario.timeoutMs,
|
|
966
|
+
successCount,
|
|
967
|
+
errorCount,
|
|
968
|
+
durationMs: round7(durationMs),
|
|
969
|
+
httpPerSec: round7(scenario.calls / Math.max(durationMs / 1e3, 1e-3)),
|
|
970
|
+
requestP50Ms: latencyPercentiles.p50,
|
|
971
|
+
requestP90Ms: latencyPercentiles.p90,
|
|
972
|
+
requestP95Ms: latencyPercentiles.p95,
|
|
973
|
+
requestP99Ms: latencyPercentiles.p99,
|
|
974
|
+
serverRequests: serverStats.requests,
|
|
975
|
+
serverMaxInFlight: serverStats.maxInFlight,
|
|
976
|
+
clientMaxInFlight: maxClientInFlight,
|
|
977
|
+
clientWireMaxInFlight: maxWireInFlight,
|
|
978
|
+
...sawPoolStats ? {
|
|
979
|
+
clientPoolMaxRunning: maxPoolRunning,
|
|
980
|
+
clientPoolMaxQueued: maxPoolQueued
|
|
981
|
+
} : {},
|
|
982
|
+
...sawAdaptiveStats ? {
|
|
983
|
+
adaptiveMinLimit: minAdaptiveLimit,
|
|
984
|
+
adaptiveMaxLimit: maxAdaptiveLimit,
|
|
985
|
+
adaptiveFinalLimit: lastClientStats?.adaptiveLimit ?? 0,
|
|
986
|
+
adaptiveMaxInFlight: maxAdaptiveInFlight,
|
|
987
|
+
adaptiveMaxQueueDepth: maxAdaptiveQueueDepth,
|
|
988
|
+
adaptiveFinalGradient: lastClientStats?.adaptiveGradient ?? 0,
|
|
989
|
+
adaptiveWindowSize: lastClientStats?.adaptiveWindowSize ?? 0
|
|
990
|
+
} : {},
|
|
991
|
+
lifecycleMaxQueueDepth: maxLifecycleQueueDepth,
|
|
992
|
+
clientStarted: delta(lastClientStats?.wireStarted, initialClientStats?.wireStarted),
|
|
993
|
+
clientSucceeded: delta(lastClientStats?.wireSucceeded, initialClientStats?.wireSucceeded),
|
|
994
|
+
clientFailed: delta(lastClientStats?.wireFailed, initialClientStats?.wireFailed),
|
|
995
|
+
clientTimedOut: delta(lastClientStats?.wireTimedOut, initialClientStats?.wireTimedOut),
|
|
996
|
+
lifecycleStarted: delta(lastClientStats?.lifecycleStarted, initialClientStats?.lifecycleStarted),
|
|
997
|
+
lifecycleCompleted: delta(lastClientStats?.lifecycleCompleted, initialClientStats?.lifecycleCompleted),
|
|
998
|
+
lifecycleFailed: delta(lastClientStats?.lifecycleFailed, initialClientStats?.lifecycleFailed),
|
|
999
|
+
observedFinishedSpans: tracerStats?.finishedSpans ?? 0,
|
|
1000
|
+
observedPrunedSpans: tracerStats?.prunedFinishedSpans ?? 0,
|
|
1001
|
+
observabilityFlushMs,
|
|
1002
|
+
gcAvailable: memoryAfter.gcAvailable,
|
|
1003
|
+
memory: {
|
|
1004
|
+
before: memoryBefore,
|
|
1005
|
+
after: memoryAfter,
|
|
1006
|
+
delta: diffMemorySnapshots(memoryBefore, memoryAfter)
|
|
1007
|
+
},
|
|
1008
|
+
firstError: firstError ? String(firstError) : void 0
|
|
1009
|
+
}));
|
|
1010
|
+
};
|
|
1011
|
+
const launch = () => {
|
|
1012
|
+
while (inFlight < scenario.concurrency && next < scenario.calls) {
|
|
1013
|
+
const id = next++;
|
|
1014
|
+
const requestId = id + (scenario.idOffset ?? 0);
|
|
1015
|
+
const requestStartedAt = performance.now();
|
|
1016
|
+
inFlight++;
|
|
1017
|
+
if (inFlight > maxClientInFlight) maxClientInFlight = inFlight;
|
|
1018
|
+
sampleClientStats();
|
|
1019
|
+
runOne(requestId, (result) => {
|
|
1020
|
+
latencies[id] = performance.now() - requestStartedAt;
|
|
1021
|
+
inFlight--;
|
|
1022
|
+
completed++;
|
|
1023
|
+
if (result.ok) {
|
|
1024
|
+
successCount++;
|
|
1025
|
+
} else {
|
|
1026
|
+
errorCount++;
|
|
1027
|
+
if (firstError === void 0) firstError = result.error ?? "unknown HTTP profiler error";
|
|
1028
|
+
}
|
|
1029
|
+
sampleClientStats();
|
|
1030
|
+
if (completed === scenario.calls) {
|
|
1031
|
+
finish();
|
|
1032
|
+
return;
|
|
1033
|
+
}
|
|
1034
|
+
launch();
|
|
1035
|
+
});
|
|
1036
|
+
}
|
|
1037
|
+
};
|
|
1038
|
+
try {
|
|
1039
|
+
launch();
|
|
1040
|
+
} catch (error) {
|
|
1041
|
+
if (sampleTimer) clearInterval(sampleTimer);
|
|
1042
|
+
reject(error);
|
|
1043
|
+
}
|
|
1044
|
+
});
|
|
1045
|
+
}
|
|
1046
|
+
function snapshotWireStats(stats) {
|
|
1047
|
+
return {
|
|
1048
|
+
wireInFlight: stats.inFlight,
|
|
1049
|
+
wireStarted: stats.started,
|
|
1050
|
+
wireSucceeded: stats.succeeded,
|
|
1051
|
+
wireFailed: stats.failed,
|
|
1052
|
+
wireTimedOut: stats.timedOut,
|
|
1053
|
+
poolRunning: stats.pool?.running,
|
|
1054
|
+
poolQueued: stats.pool?.queued,
|
|
1055
|
+
adaptiveLimit: stats.adaptiveLimiter?.limit,
|
|
1056
|
+
adaptiveInFlight: stats.adaptiveLimiter?.inFlight,
|
|
1057
|
+
adaptiveQueueDepth: stats.adaptiveLimiter?.queueDepth,
|
|
1058
|
+
adaptiveGradient: stats.adaptiveLimiter?.gradient,
|
|
1059
|
+
adaptiveWindowSize: stats.adaptiveLimiter?.windowSize
|
|
1060
|
+
};
|
|
1061
|
+
}
|
|
1062
|
+
function snapshotLifecycleStats(stats) {
|
|
1063
|
+
return {
|
|
1064
|
+
...snapshotWireStats(stats.wire),
|
|
1065
|
+
lifecycleQueueDepth: stats.queueDepth,
|
|
1066
|
+
lifecycleStarted: stats.requestsStarted,
|
|
1067
|
+
lifecycleCompleted: stats.requestsCompleted,
|
|
1068
|
+
lifecycleFailed: stats.requestsFailed
|
|
1069
|
+
};
|
|
1070
|
+
}
|
|
1071
|
+
function flushObservability(observability) {
|
|
1072
|
+
if (!observability) return 0;
|
|
1073
|
+
const startedAt = performance.now();
|
|
1074
|
+
observability.hooks.flush?.(Number.MAX_SAFE_INTEGER);
|
|
1075
|
+
return round7(performance.now() - startedAt);
|
|
1076
|
+
}
|
|
1077
|
+
function dropFinishedSpans(observability) {
|
|
1078
|
+
if (!observability) return;
|
|
1079
|
+
const spanIds = observability.tracer.exportFinished().map((span) => span.spanId);
|
|
1080
|
+
if (spanIds.length > 0) observability.tracer.pruneFinished(spanIds);
|
|
1081
|
+
}
|
|
1082
|
+
function percentiles(samples) {
|
|
1083
|
+
const sorted = samples.filter((value) => Number.isFinite(value)).sort((a, b) => a - b);
|
|
1084
|
+
return {
|
|
1085
|
+
p50: round7(percentile(sorted, 0.5)),
|
|
1086
|
+
p90: round7(percentile(sorted, 0.9)),
|
|
1087
|
+
p95: round7(percentile(sorted, 0.95)),
|
|
1088
|
+
p99: round7(percentile(sorted, 0.99))
|
|
1089
|
+
};
|
|
1090
|
+
}
|
|
1091
|
+
function percentile(sorted, p) {
|
|
1092
|
+
if (sorted.length === 0) return 0;
|
|
1093
|
+
const idx = Math.ceil(p * sorted.length) - 1;
|
|
1094
|
+
return sorted[Math.max(0, idx)] ?? 0;
|
|
1095
|
+
}
|
|
1096
|
+
function labelForVariant2(variant) {
|
|
1097
|
+
switch (variant) {
|
|
1098
|
+
case "node-http-text":
|
|
1099
|
+
return "node:http transport text";
|
|
1100
|
+
case "wire-raw":
|
|
1101
|
+
return "brass wire raw";
|
|
1102
|
+
case "default-minimal-json":
|
|
1103
|
+
return "default client minimal JSON";
|
|
1104
|
+
case "default-balanced-no-adaptive-json":
|
|
1105
|
+
return "default client balanced JSON without adaptive limiter";
|
|
1106
|
+
case "default-balanced-json":
|
|
1107
|
+
return "default client balanced JSON";
|
|
1108
|
+
case "default-json":
|
|
1109
|
+
return "default client JSON";
|
|
1110
|
+
case "default-json-observed":
|
|
1111
|
+
return "default client JSON + observability";
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
function normalizeVariants(value) {
|
|
1115
|
+
if (!value || value.length === 0) return HTTP_PROFILE_VARIANTS;
|
|
1116
|
+
const allowed = new Set(HTTP_PROFILE_VARIANTS);
|
|
1117
|
+
const variants = value.filter((variant) => allowed.has(variant));
|
|
1118
|
+
return Object.freeze(variants.length > 0 ? variants : [...HTTP_PROFILE_VARIANTS]);
|
|
1119
|
+
}
|
|
1120
|
+
function delta(current, baseline) {
|
|
1121
|
+
return Math.max(0, (current ?? 0) - (baseline ?? 0));
|
|
1122
|
+
}
|
|
1123
|
+
function positiveInt3(value, fallback) {
|
|
1124
|
+
if (value === void 0 || !Number.isFinite(value)) return fallback;
|
|
1125
|
+
return Math.max(1, Math.floor(value));
|
|
1126
|
+
}
|
|
1127
|
+
function nonNegativeInt(value, fallback) {
|
|
1128
|
+
if (value === void 0 || !Number.isFinite(value)) return fallback;
|
|
1129
|
+
return Math.max(0, Math.floor(value));
|
|
1130
|
+
}
|
|
1131
|
+
function round7(n) {
|
|
1132
|
+
return Math.round(n * 1e3) / 1e3;
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
// src/perf/httpMemoryLab.ts
|
|
1136
|
+
var DEFAULT_MEMORY_VARIANTS = [
|
|
1137
|
+
"node-http-text",
|
|
1138
|
+
"wire-raw",
|
|
1139
|
+
"default-minimal-json",
|
|
1140
|
+
"default-balanced-no-adaptive-json",
|
|
1141
|
+
"default-balanced-json",
|
|
1142
|
+
"default-json",
|
|
1143
|
+
"default-json-observed"
|
|
1144
|
+
];
|
|
1145
|
+
async function profileHttpMemoryLab(options = {}) {
|
|
1146
|
+
const calls = positiveInt4(options.calls, 2e4);
|
|
1147
|
+
const concurrency = positiveInt4(options.concurrency, 512);
|
|
1148
|
+
const delayMs = nonNegativeInt2(options.delayMs, 2);
|
|
1149
|
+
const timeoutMs = positiveInt4(options.timeoutMs, 3e4);
|
|
1150
|
+
const warmupCalls = nonNegativeInt2(options.warmupCalls, Math.min(2e3, Math.floor(calls / 10)));
|
|
1151
|
+
const statsSampleMs = positiveInt4(options.statsSampleMs, 10);
|
|
1152
|
+
const rounds = positiveInt4(options.rounds, 1);
|
|
1153
|
+
const forceGc2 = options.forceGc ?? true;
|
|
1154
|
+
const variants = options.variants && options.variants.length > 0 ? options.variants : DEFAULT_MEMORY_VARIANTS;
|
|
1155
|
+
const roundsData = [];
|
|
1156
|
+
for (let i = 0; i < rounds; i++) {
|
|
1157
|
+
const report = await profileHttpLayers({
|
|
1158
|
+
calls,
|
|
1159
|
+
concurrency,
|
|
1160
|
+
delayMs,
|
|
1161
|
+
timeoutMs,
|
|
1162
|
+
warmupCalls,
|
|
1163
|
+
statsSampleMs,
|
|
1164
|
+
forceGc: forceGc2,
|
|
1165
|
+
variants
|
|
1166
|
+
});
|
|
1167
|
+
roundsData.push(Object.freeze({
|
|
1168
|
+
round: i + 1,
|
|
1169
|
+
results: report.results
|
|
1170
|
+
}));
|
|
1171
|
+
}
|
|
1172
|
+
const summaries = summarizeByVariant(roundsData, {
|
|
1173
|
+
calls,
|
|
1174
|
+
heapWarnMb: options.heapWarnMb ?? 16,
|
|
1175
|
+
heapCriticalMb: options.heapCriticalMb ?? 64,
|
|
1176
|
+
heapPer10kWarnMb: options.heapPer10kWarnMb ?? 4
|
|
1177
|
+
});
|
|
1178
|
+
return Object.freeze({
|
|
1179
|
+
calls,
|
|
1180
|
+
concurrency,
|
|
1181
|
+
delayMs,
|
|
1182
|
+
warmupCalls,
|
|
1183
|
+
rounds,
|
|
1184
|
+
forceGc: forceGc2,
|
|
1185
|
+
variants: Object.freeze([...variants]),
|
|
1186
|
+
summaries,
|
|
1187
|
+
roundsData: Object.freeze(roundsData),
|
|
1188
|
+
recommendations: makeHttpMemoryRecommendations(summaries, forceGc2)
|
|
1189
|
+
});
|
|
1190
|
+
}
|
|
1191
|
+
function formatHttpMemoryLabReport(report) {
|
|
1192
|
+
const lines = [];
|
|
1193
|
+
lines.push("Brass HTTP Long-Run Memory Lab");
|
|
1194
|
+
lines.push(`calls=${report.calls} concurrency=${report.concurrency} delay=${report.delayMs}ms rounds=${report.rounds} forceGc=${report.forceGc}`);
|
|
1195
|
+
lines.push("");
|
|
1196
|
+
lines.push("Variants");
|
|
1197
|
+
for (const summary of report.summaries) {
|
|
1198
|
+
lines.push(`- ${summary.variant}: ${formatNumber3(summary.meanHttpPerSec)} http/s, maxP99=${summary.maxP99Ms}ms, heap=${summary.totalHeapDeltaMb}MB (${summary.heapDeltaPer10kRequestsMb}MB/10k), rss=${summary.totalRssDeltaMb}MB, errors=${summary.totalErrors}, verdict=${summary.verdict}`);
|
|
1199
|
+
for (const note of summary.notes) lines.push(` note: ${note}`);
|
|
1200
|
+
}
|
|
1201
|
+
lines.push("");
|
|
1202
|
+
lines.push("Recommendations");
|
|
1203
|
+
for (const recommendation of report.recommendations) lines.push(`- ${recommendation}`);
|
|
1204
|
+
return lines.join("\n");
|
|
1205
|
+
}
|
|
1206
|
+
function summarizeByVariant(roundsData, thresholds) {
|
|
1207
|
+
const byVariant = /* @__PURE__ */ new Map();
|
|
1208
|
+
for (const round10 of roundsData) {
|
|
1209
|
+
for (const result of round10.results) {
|
|
1210
|
+
const items = byVariant.get(result.variant) ?? [];
|
|
1211
|
+
items.push(result);
|
|
1212
|
+
byVariant.set(result.variant, items);
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
return Object.freeze([...byVariant.entries()].map(([variant, results]) => {
|
|
1216
|
+
const totalCalls = results.reduce((sum2, result) => sum2 + result.calls, 0);
|
|
1217
|
+
const heapDeltas = results.map((result) => result.memory.delta.heapUsedMb);
|
|
1218
|
+
const rssDeltas = results.map((result) => result.memory.delta.rssMb);
|
|
1219
|
+
const totalHeapDeltaMb = round8(sum(heapDeltas));
|
|
1220
|
+
const totalRssDeltaMb = round8(sum(rssDeltas));
|
|
1221
|
+
const heapDeltaPer10kRequestsMb = round8(totalHeapDeltaMb / Math.max(totalCalls / 1e4, 1));
|
|
1222
|
+
const gcAvailable = results.every((result) => result.gcAvailable);
|
|
1223
|
+
const maxHeapDeltaMb = round8(Math.max(...heapDeltas));
|
|
1224
|
+
const maxRssDeltaMb = round8(Math.max(...rssDeltas));
|
|
1225
|
+
const verdict = memoryVerdict({
|
|
1226
|
+
gcAvailable,
|
|
1227
|
+
totalHeapDeltaMb,
|
|
1228
|
+
heapDeltaPer10kRequestsMb,
|
|
1229
|
+
heapWarnMb: thresholds.heapWarnMb,
|
|
1230
|
+
heapCriticalMb: thresholds.heapCriticalMb,
|
|
1231
|
+
heapPer10kWarnMb: thresholds.heapPer10kWarnMb
|
|
1232
|
+
});
|
|
1233
|
+
return Object.freeze({
|
|
1234
|
+
variant,
|
|
1235
|
+
label: results[0]?.label ?? variant,
|
|
1236
|
+
rounds: results.length,
|
|
1237
|
+
callsPerRound: thresholds.calls,
|
|
1238
|
+
totalCalls,
|
|
1239
|
+
meanHttpPerSec: round8(mean(results.map((result) => result.httpPerSec))),
|
|
1240
|
+
minHttpPerSec: round8(Math.min(...results.map((result) => result.httpPerSec))),
|
|
1241
|
+
maxHttpPerSec: round8(Math.max(...results.map((result) => result.httpPerSec))),
|
|
1242
|
+
maxP99Ms: round8(Math.max(...results.map((result) => result.requestP99Ms))),
|
|
1243
|
+
totalHeapDeltaMb,
|
|
1244
|
+
maxHeapDeltaMb,
|
|
1245
|
+
heapDeltaPer10kRequestsMb,
|
|
1246
|
+
totalRssDeltaMb,
|
|
1247
|
+
maxRssDeltaMb,
|
|
1248
|
+
totalErrors: results.reduce((sum2, result) => sum2 + result.errorCount, 0),
|
|
1249
|
+
gcAvailable,
|
|
1250
|
+
observedFinishedSpans: results.reduce((sum2, result) => sum2 + (result.observedFinishedSpans ?? 0), 0),
|
|
1251
|
+
adaptiveFinalLimit: lastDefined(results.map((result) => result.adaptiveFinalLimit)),
|
|
1252
|
+
verdict,
|
|
1253
|
+
notes: Object.freeze(notesForVariant(variant, verdict, gcAvailable, heapDeltaPer10kRequestsMb))
|
|
1254
|
+
});
|
|
1255
|
+
}));
|
|
1256
|
+
}
|
|
1257
|
+
function memoryVerdict(input) {
|
|
1258
|
+
if (!input.gcAvailable) return "unknown-gc";
|
|
1259
|
+
if (input.totalHeapDeltaMb >= input.heapCriticalMb) return "critical";
|
|
1260
|
+
if (input.totalHeapDeltaMb >= input.heapWarnMb) return "watch";
|
|
1261
|
+
if (input.heapDeltaPer10kRequestsMb >= input.heapPer10kWarnMb) return "watch";
|
|
1262
|
+
return "ok";
|
|
1263
|
+
}
|
|
1264
|
+
function notesForVariant(variant, verdict, gcAvailable, heapDeltaPer10kRequestsMb) {
|
|
1265
|
+
const notes = [];
|
|
1266
|
+
if (!gcAvailable) notes.push("GC was unavailable; heap delta may be allocator churn rather than retained memory.");
|
|
1267
|
+
if (verdict === "watch") notes.push(`Retained heap is worth watching at ${heapDeltaPer10kRequestsMb}MB per 10k requests.`);
|
|
1268
|
+
if (verdict === "critical") notes.push("Retained heap crossed the critical threshold after GC.");
|
|
1269
|
+
if (variant === "default-json-observed") notes.push("Includes client observability middleware and span retention pressure.");
|
|
1270
|
+
if (variant === "default-json") notes.push("Default preset includes adaptive limiter and safe-method cache policy.");
|
|
1271
|
+
if (variant === "default-balanced-no-adaptive-json") notes.push("Balanced preset without adaptive limiter isolates limiter overhead.");
|
|
1272
|
+
return Object.freeze(notes);
|
|
1273
|
+
}
|
|
1274
|
+
function makeHttpMemoryRecommendations(summaries, forceGc2) {
|
|
1275
|
+
const recommendations = [];
|
|
1276
|
+
if (!summaries.every((summary) => summary.gcAvailable)) {
|
|
1277
|
+
recommendations.push("Re-run with node --expose-gc to distinguish allocator churn from retained references.");
|
|
1278
|
+
} else if (forceGc2) {
|
|
1279
|
+
recommendations.push("GC was available; positive heap deltas are stronger retention signals than non-GC runs.");
|
|
1280
|
+
}
|
|
1281
|
+
const critical = summaries.filter((summary) => summary.verdict === "critical");
|
|
1282
|
+
const watch = summaries.filter((summary) => summary.verdict === "watch");
|
|
1283
|
+
if (critical.length > 0) {
|
|
1284
|
+
recommendations.push(`Critical retained heap in: ${critical.map((summary) => summary.variant).join(", ")}.`);
|
|
1285
|
+
} else if (watch.length > 0) {
|
|
1286
|
+
recommendations.push(`Watch retained heap in: ${watch.map((summary) => summary.variant).join(", ")}.`);
|
|
1287
|
+
} else if (summaries.every((summary) => summary.gcAvailable)) {
|
|
1288
|
+
recommendations.push("No variant crossed retained-heap thresholds after GC.");
|
|
1289
|
+
}
|
|
1290
|
+
const observed = summaries.find((summary) => summary.variant === "default-json-observed");
|
|
1291
|
+
const def = summaries.find((summary) => summary.variant === "default-json");
|
|
1292
|
+
if (observed && def) {
|
|
1293
|
+
const observedHeapOverDefault = round8(observed.heapDeltaPer10kRequestsMb - def.heapDeltaPer10kRequestsMb);
|
|
1294
|
+
const throughputRatio = def.meanHttpPerSec > 0 ? round8(observed.meanHttpPerSec / def.meanHttpPerSec * 100) : 100;
|
|
1295
|
+
recommendations.push(`Observability vs default: heapDeltaPer10k ${signed2(observedHeapOverDefault)}MB, throughput ${throughputRatio}% of default.`);
|
|
1296
|
+
}
|
|
1297
|
+
const lowestHeap = [...summaries].sort((a, b) => a.heapDeltaPer10kRequestsMb - b.heapDeltaPer10kRequestsMb)[0];
|
|
1298
|
+
const fastest = [...summaries].sort((a, b) => b.meanHttpPerSec - a.meanHttpPerSec)[0];
|
|
1299
|
+
if (lowestHeap) recommendations.push(`Lowest heap/10k variant: ${lowestHeap.variant} (${lowestHeap.heapDeltaPer10kRequestsMb}MB/10k).`);
|
|
1300
|
+
if (fastest) recommendations.push(`Fastest variant: ${fastest.variant} (${formatNumber3(fastest.meanHttpPerSec)} http/s).`);
|
|
1301
|
+
return Object.freeze(recommendations);
|
|
1302
|
+
}
|
|
1303
|
+
function positiveInt4(value, fallback) {
|
|
1304
|
+
if (value === void 0 || !Number.isFinite(value)) return fallback;
|
|
1305
|
+
return Math.max(1, Math.floor(value));
|
|
1306
|
+
}
|
|
1307
|
+
function nonNegativeInt2(value, fallback) {
|
|
1308
|
+
if (value === void 0 || !Number.isFinite(value)) return fallback;
|
|
1309
|
+
return Math.max(0, Math.floor(value));
|
|
1310
|
+
}
|
|
1311
|
+
function mean(values) {
|
|
1312
|
+
return values.length === 0 ? 0 : sum(values) / values.length;
|
|
1313
|
+
}
|
|
1314
|
+
function sum(values) {
|
|
1315
|
+
return values.reduce((acc, value) => acc + value, 0);
|
|
1316
|
+
}
|
|
1317
|
+
function lastDefined(values) {
|
|
1318
|
+
for (let i = values.length - 1; i >= 0; i--) {
|
|
1319
|
+
if (values[i] !== void 0) return values[i];
|
|
1320
|
+
}
|
|
1321
|
+
return void 0;
|
|
1322
|
+
}
|
|
1323
|
+
function signed2(value) {
|
|
1324
|
+
return value >= 0 ? `+${value}` : String(value);
|
|
1325
|
+
}
|
|
1326
|
+
function formatNumber3(value) {
|
|
1327
|
+
return value >= 1e3 ? value.toLocaleString("en-US", { maximumFractionDigits: 0 }) : value.toFixed(3);
|
|
1328
|
+
}
|
|
1329
|
+
function round8(n) {
|
|
1330
|
+
return Math.round(n * 1e3) / 1e3;
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
// src/perf/history.ts
|
|
1334
|
+
import { appendFile, mkdir, readFile, writeFile } from "fs/promises";
|
|
1335
|
+
import { join, resolve } from "path";
|
|
1336
|
+
var DEFAULT_HISTORY_DIR = ".brass/perf-history";
|
|
1337
|
+
var DEFAULT_HISTORY_FILE = "runs.jsonl";
|
|
1338
|
+
var DEFAULT_BASELINES_DIR = "baselines";
|
|
1339
|
+
function defaultPerfHistoryDirectory(cwd = process.cwd()) {
|
|
1340
|
+
return join(cwd, DEFAULT_HISTORY_DIR);
|
|
1341
|
+
}
|
|
1342
|
+
function createPerfHistoryEntry(profile, report, options = {}) {
|
|
1343
|
+
const timestamp = (/* @__PURE__ */ new Date()).toISOString();
|
|
1344
|
+
const metrics = extractPerfMetrics(profile, report);
|
|
1345
|
+
return Object.freeze({
|
|
1346
|
+
id: makeEntryId(profile, timestamp),
|
|
1347
|
+
timestamp,
|
|
1348
|
+
profile,
|
|
1349
|
+
nodeVersion: process.version,
|
|
1350
|
+
platform: process.platform,
|
|
1351
|
+
arch: process.arch,
|
|
1352
|
+
metrics,
|
|
1353
|
+
...options.metadata ? { metadata: Object.freeze({ ...options.metadata }) } : {},
|
|
1354
|
+
...options.includeReport ? { report } : {}
|
|
1355
|
+
});
|
|
1356
|
+
}
|
|
1357
|
+
async function recordPerfHistoryRun(profile, report, options = {}) {
|
|
1358
|
+
const entry = createPerfHistoryEntry(profile, report, options);
|
|
1359
|
+
const path = await writePerfHistoryEntry(entry, options);
|
|
1360
|
+
return Object.freeze({ entry, path });
|
|
1361
|
+
}
|
|
1362
|
+
async function writePerfHistoryEntry(entry, options = {}) {
|
|
1363
|
+
const paths = resolveStorePaths(options);
|
|
1364
|
+
await mkdir(paths.directory, { recursive: true });
|
|
1365
|
+
await appendFile(paths.historyFile, `${JSON.stringify(entry)}
|
|
1366
|
+
`, "utf8");
|
|
1367
|
+
if (options.maxEntries && options.maxEntries > 0) {
|
|
1368
|
+
await pruneHistoryFile(paths.historyFile, Math.floor(options.maxEntries));
|
|
1369
|
+
}
|
|
1370
|
+
return paths.historyFile;
|
|
1371
|
+
}
|
|
1372
|
+
async function readPerfHistory(options = {}) {
|
|
1373
|
+
const paths = resolveStorePaths(options);
|
|
1374
|
+
let raw = "";
|
|
1375
|
+
try {
|
|
1376
|
+
raw = await readFile(paths.historyFile, "utf8");
|
|
1377
|
+
} catch (error) {
|
|
1378
|
+
if (isNodeError(error) && error.code === "ENOENT") return Object.freeze([]);
|
|
1379
|
+
throw error;
|
|
1380
|
+
}
|
|
1381
|
+
const entries = [];
|
|
1382
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
1383
|
+
const trimmed = line.trim();
|
|
1384
|
+
if (!trimmed) continue;
|
|
1385
|
+
try {
|
|
1386
|
+
entries.push(JSON.parse(trimmed));
|
|
1387
|
+
} catch {
|
|
1388
|
+
}
|
|
1389
|
+
}
|
|
1390
|
+
return Object.freeze(entries);
|
|
1391
|
+
}
|
|
1392
|
+
async function savePerfBaseline(name, entry, options = {}) {
|
|
1393
|
+
const paths = resolveStorePaths(options);
|
|
1394
|
+
await mkdir(paths.baselinesDirectory, { recursive: true });
|
|
1395
|
+
const baseline = Object.freeze({
|
|
1396
|
+
name,
|
|
1397
|
+
savedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1398
|
+
entry
|
|
1399
|
+
});
|
|
1400
|
+
const path = baselinePath(paths, name);
|
|
1401
|
+
await writeFile(path, `${JSON.stringify(baseline, null, 2)}
|
|
1402
|
+
`, "utf8");
|
|
1403
|
+
return Object.freeze({ baseline, path });
|
|
1404
|
+
}
|
|
1405
|
+
async function loadPerfBaseline(name, options = {}) {
|
|
1406
|
+
const paths = resolveStorePaths(options);
|
|
1407
|
+
try {
|
|
1408
|
+
return JSON.parse(await readFile(baselinePath(paths, name), "utf8"));
|
|
1409
|
+
} catch (error) {
|
|
1410
|
+
if (isNodeError(error) && error.code === "ENOENT") return void 0;
|
|
1411
|
+
throw error;
|
|
1412
|
+
}
|
|
1413
|
+
}
|
|
1414
|
+
function comparePerfToBaseline(current, baseline, thresholds = {}) {
|
|
1415
|
+
const normalized = normalizeThresholds2(thresholds);
|
|
1416
|
+
const currentByKey = new Map(current.metrics.map((metric) => [metricKey(metric), metric]));
|
|
1417
|
+
const items = [];
|
|
1418
|
+
const missingMetrics = [];
|
|
1419
|
+
for (const baselineMetric of baseline.entry.metrics) {
|
|
1420
|
+
if (baselineMetric.direction === "neutral") continue;
|
|
1421
|
+
const currentMetric = currentByKey.get(metricKey(baselineMetric));
|
|
1422
|
+
if (!currentMetric) {
|
|
1423
|
+
missingMetrics.push(formatMetricName(baselineMetric));
|
|
1424
|
+
continue;
|
|
1425
|
+
}
|
|
1426
|
+
const item = compareMetric(currentMetric, baselineMetric, normalized);
|
|
1427
|
+
items.push(item);
|
|
1428
|
+
}
|
|
1429
|
+
const hasFailures = items.some((item) => item.status === "fail") || normalized.failOnMissingMetric && missingMetrics.length > 0;
|
|
1430
|
+
const hasWarnings = items.some((item) => item.status === "warn") || !normalized.failOnMissingMetric && missingMetrics.length > 0;
|
|
1431
|
+
const status = hasFailures ? "fail" : hasWarnings ? "warn" : "pass";
|
|
1432
|
+
return Object.freeze({
|
|
1433
|
+
baselineName: baseline.name,
|
|
1434
|
+
baselineId: baseline.entry.id,
|
|
1435
|
+
currentId: current.id,
|
|
1436
|
+
comparedMetrics: items.length,
|
|
1437
|
+
missingMetrics: Object.freeze(missingMetrics),
|
|
1438
|
+
status,
|
|
1439
|
+
passed: status !== "fail",
|
|
1440
|
+
items: Object.freeze(items)
|
|
1441
|
+
});
|
|
1442
|
+
}
|
|
1443
|
+
function formatPerfBaselineComparison(comparison) {
|
|
1444
|
+
const lines = [];
|
|
1445
|
+
lines.push(`Baseline comparison: ${comparison.baselineName} status=${comparison.status}`);
|
|
1446
|
+
lines.push(`compared=${comparison.comparedMetrics} missing=${comparison.missingMetrics.length}`);
|
|
1447
|
+
for (const item of comparison.items.filter((candidate) => candidate.status !== "pass")) {
|
|
1448
|
+
const sign = item.deltaPercent >= 0 ? "+" : "";
|
|
1449
|
+
lines.push(`- [${item.status}] ${formatMetricName(item.metric)} ${item.baselineValue} -> ${item.currentValue} (${sign}${item.deltaPercent}%)`);
|
|
1450
|
+
lines.push(` ${item.reason}`);
|
|
1451
|
+
}
|
|
1452
|
+
for (const missing of comparison.missingMetrics) {
|
|
1453
|
+
lines.push(`- [warn] missing metric: ${missing}`);
|
|
1454
|
+
}
|
|
1455
|
+
if (comparison.items.every((item) => item.status === "pass") && comparison.missingMetrics.length === 0) {
|
|
1456
|
+
lines.push("- all comparable metrics stayed within baseline thresholds");
|
|
1457
|
+
}
|
|
1458
|
+
return lines.join("\n");
|
|
1459
|
+
}
|
|
1460
|
+
function extractPerfMetrics(profile, report) {
|
|
1461
|
+
const metrics = [];
|
|
1462
|
+
if (isBrassPerformanceReport(report)) {
|
|
1463
|
+
if (report.runtime) pushRuntimeMetrics(metrics, "runtime", report.runtime);
|
|
1464
|
+
if (report.http) pushHttpMetrics(metrics, "http", report.http);
|
|
1465
|
+
if (report.memory) {
|
|
1466
|
+
pushMetric(metrics, "profile.heap_delta_mb", report.memory.delta.heapUsedMb, "MB", "lower-is-better", { profile });
|
|
1467
|
+
pushMetric(metrics, "profile.rss_delta_mb", report.memory.delta.rssMb, "MB", "lower-is-better", { profile });
|
|
1468
|
+
}
|
|
1469
|
+
return Object.freeze(metrics);
|
|
1470
|
+
}
|
|
1471
|
+
if (isRuntimeAbReport(report)) {
|
|
1472
|
+
pushRuntimeMetrics(metrics, "runtime_ab.baseline", report.baseline);
|
|
1473
|
+
pushRuntimeMetrics(metrics, "runtime_ab.candidate", report.candidate);
|
|
1474
|
+
for (const comparison of report.comparisons) {
|
|
1475
|
+
pushMetric(metrics, "runtime_ab.delta_percent", comparison.deltaPercent, "%", "higher-is-better", {
|
|
1476
|
+
primitive: comparison.primitive,
|
|
1477
|
+
baseline: report.baselineVariant,
|
|
1478
|
+
candidate: report.candidateVariant
|
|
1479
|
+
});
|
|
1480
|
+
}
|
|
1481
|
+
pushMetric(metrics, "runtime_ab.heap_delta_mb", report.memory.delta.heapUsedMb, "MB", "lower-is-better", { profile });
|
|
1482
|
+
pushMetric(metrics, "runtime_ab.rss_delta_mb", report.memory.delta.rssMb, "MB", "lower-is-better", { profile });
|
|
1483
|
+
return Object.freeze(metrics);
|
|
1484
|
+
}
|
|
1485
|
+
if (isRuntimeSoakReport(report)) {
|
|
1486
|
+
pushMetric(metrics, "runtime_soak.throughput_trend_percent", report.throughputTrendPercent, "%", "higher-is-better", { variant: report.variant });
|
|
1487
|
+
pushMetric(metrics, "runtime_soak.heap_trend_mb", report.heapTrendMb, "MB", "lower-is-better", { variant: report.variant });
|
|
1488
|
+
pushMetric(metrics, "runtime_soak.heap_delta_mb", report.memory.delta.heapUsedMb, "MB", "lower-is-better", { variant: report.variant });
|
|
1489
|
+
for (const round10 of report.rounds) {
|
|
1490
|
+
pushMetric(metrics, "runtime_soak.aggregate_ops_per_second", aggregateRuntimeOps(round10.report), "ops/s", "higher-is-better", {
|
|
1491
|
+
variant: report.variant,
|
|
1492
|
+
round: String(round10.round)
|
|
1493
|
+
});
|
|
1494
|
+
}
|
|
1495
|
+
return Object.freeze(metrics);
|
|
1496
|
+
}
|
|
1497
|
+
if (isHttpMemoryLabReport(report)) {
|
|
1498
|
+
for (const summary of report.summaries) pushHttpMemoryMetrics(metrics, summary);
|
|
1499
|
+
return Object.freeze(metrics);
|
|
1500
|
+
}
|
|
1501
|
+
if (isHttpLayerProfileReport(report)) {
|
|
1502
|
+
pushHttpMetrics(metrics, "http", report);
|
|
1503
|
+
return Object.freeze(metrics);
|
|
1504
|
+
}
|
|
1505
|
+
if (isRuntimePrimitiveProfileReport(report)) {
|
|
1506
|
+
pushRuntimeMetrics(metrics, "runtime", report);
|
|
1507
|
+
}
|
|
1508
|
+
return Object.freeze(metrics);
|
|
1509
|
+
}
|
|
1510
|
+
function pushRuntimeMetrics(metrics, prefix, report) {
|
|
1511
|
+
for (const result of report.results) {
|
|
1512
|
+
const tags = { variant: report.variant, primitive: result.name };
|
|
1513
|
+
pushMetric(metrics, `${prefix}.ops_per_second`, result.operationsPerSecond, `${result.unit}/s`, "higher-is-better", tags);
|
|
1514
|
+
pushMetric(metrics, `${prefix}.ns_per_operation`, result.nsPerOperation, "ns/op", "lower-is-better", tags);
|
|
1515
|
+
pushMetric(metrics, `${prefix}.fibers_per_1000_ops`, result.fibersPerThousandOps, "fibers/1k", "lower-is-better", tags);
|
|
1516
|
+
}
|
|
1517
|
+
}
|
|
1518
|
+
function pushHttpMetrics(metrics, prefix, report) {
|
|
1519
|
+
for (const result of report.results) {
|
|
1520
|
+
const tags = { variant: result.variant };
|
|
1521
|
+
pushMetric(metrics, `${prefix}.requests_per_second`, result.httpPerSec, "http/s", "higher-is-better", tags);
|
|
1522
|
+
pushMetric(metrics, `${prefix}.p99_ms`, result.requestP99Ms, "ms", "lower-is-better", tags);
|
|
1523
|
+
pushMetric(metrics, `${prefix}.heap_delta_mb`, result.memory.delta.heapUsedMb, "MB", "lower-is-better", tags);
|
|
1524
|
+
pushMetric(metrics, `${prefix}.rss_delta_mb`, result.memory.delta.rssMb, "MB", "lower-is-better", tags);
|
|
1525
|
+
pushMetric(metrics, `${prefix}.errors`, result.errorCount, "count", "lower-is-better", tags);
|
|
1526
|
+
}
|
|
1527
|
+
}
|
|
1528
|
+
function pushHttpMemoryMetrics(metrics, summary) {
|
|
1529
|
+
const tags = { variant: summary.variant };
|
|
1530
|
+
pushMetric(metrics, "http_memory.mean_requests_per_second", summary.meanHttpPerSec, "http/s", "higher-is-better", tags);
|
|
1531
|
+
pushMetric(metrics, "http_memory.max_p99_ms", summary.maxP99Ms, "ms", "lower-is-better", tags);
|
|
1532
|
+
pushMetric(metrics, "http_memory.heap_per_10k_mb", summary.heapDeltaPer10kRequestsMb, "MB/10k", "lower-is-better", tags);
|
|
1533
|
+
pushMetric(metrics, "http_memory.total_heap_delta_mb", summary.totalHeapDeltaMb, "MB", "lower-is-better", tags);
|
|
1534
|
+
pushMetric(metrics, "http_memory.total_rss_delta_mb", summary.totalRssDeltaMb, "MB", "lower-is-better", tags);
|
|
1535
|
+
pushMetric(metrics, "http_memory.errors", summary.totalErrors, "count", "lower-is-better", tags);
|
|
1536
|
+
}
|
|
1537
|
+
function pushMetric(metrics, name, value, unit, direction, tags) {
|
|
1538
|
+
if (!Number.isFinite(value)) return;
|
|
1539
|
+
metrics.push(Object.freeze({
|
|
1540
|
+
name,
|
|
1541
|
+
value: round9(value),
|
|
1542
|
+
unit,
|
|
1543
|
+
direction,
|
|
1544
|
+
...tags ? { tags: Object.freeze({ ...tags }) } : {}
|
|
1545
|
+
}));
|
|
1546
|
+
}
|
|
1547
|
+
function compareMetric(current, baseline, thresholds) {
|
|
1548
|
+
const delta2 = round9(current.value - baseline.value);
|
|
1549
|
+
const deltaPercent = round9(percentDelta2(current.value, baseline.value));
|
|
1550
|
+
const regressionPercent = regressionFor(current, baseline);
|
|
1551
|
+
const limit = isHeapLikeMetric(current) ? thresholds.maxHeapRegressionPercent : thresholds.maxRegressionPercent;
|
|
1552
|
+
const warnLimit = limit * thresholds.warnAtRatio;
|
|
1553
|
+
const status = regressionPercent > limit ? "fail" : regressionPercent > warnLimit ? "warn" : "pass";
|
|
1554
|
+
return Object.freeze({
|
|
1555
|
+
metric: current,
|
|
1556
|
+
baselineValue: baseline.value,
|
|
1557
|
+
currentValue: current.value,
|
|
1558
|
+
delta: delta2,
|
|
1559
|
+
deltaPercent,
|
|
1560
|
+
status,
|
|
1561
|
+
reason: reasonForMetric(current, regressionPercent, limit)
|
|
1562
|
+
});
|
|
1563
|
+
}
|
|
1564
|
+
function regressionFor(current, baseline) {
|
|
1565
|
+
if (current.direction === "higher-is-better") {
|
|
1566
|
+
if (baseline.value <= 0) return current.value < baseline.value ? 100 : 0;
|
|
1567
|
+
return Math.max(0, (baseline.value - current.value) / baseline.value * 100);
|
|
1568
|
+
}
|
|
1569
|
+
if (current.direction === "lower-is-better") {
|
|
1570
|
+
if (baseline.value === 0) return current.value > 0 ? 100 : 0;
|
|
1571
|
+
return Math.max(0, (current.value - baseline.value) / Math.abs(baseline.value) * 100);
|
|
1572
|
+
}
|
|
1573
|
+
return 0;
|
|
1574
|
+
}
|
|
1575
|
+
function reasonForMetric(metric, regressionPercent, limit) {
|
|
1576
|
+
if (regressionPercent <= 0) return "no regression against baseline";
|
|
1577
|
+
return `${formatMetricName(metric)} regressed ${round9(regressionPercent)}% (limit ${limit}%)`;
|
|
1578
|
+
}
|
|
1579
|
+
function resolveStorePaths(options) {
|
|
1580
|
+
const directory = resolve(options.directory ?? defaultPerfHistoryDirectory());
|
|
1581
|
+
return Object.freeze({
|
|
1582
|
+
directory,
|
|
1583
|
+
historyFile: join(directory, options.historyFileName ?? DEFAULT_HISTORY_FILE),
|
|
1584
|
+
baselinesDirectory: join(directory, options.baselinesDirectoryName ?? DEFAULT_BASELINES_DIR)
|
|
1585
|
+
});
|
|
1586
|
+
}
|
|
1587
|
+
function baselinePath(paths, name) {
|
|
1588
|
+
return join(paths.baselinesDirectory, `${sanitizeBaselineName(name)}.json`);
|
|
1589
|
+
}
|
|
1590
|
+
function sanitizeBaselineName(name) {
|
|
1591
|
+
let safe = "";
|
|
1592
|
+
let lastWasReplacement = false;
|
|
1593
|
+
for (const char of name.trim()) {
|
|
1594
|
+
if (isSafeBaselineNameChar(char)) {
|
|
1595
|
+
safe += char;
|
|
1596
|
+
lastWasReplacement = false;
|
|
1597
|
+
continue;
|
|
1598
|
+
}
|
|
1599
|
+
if (safe.length > 0 && !lastWasReplacement) {
|
|
1600
|
+
safe += "-";
|
|
1601
|
+
lastWasReplacement = true;
|
|
1602
|
+
}
|
|
1603
|
+
}
|
|
1604
|
+
let start = 0;
|
|
1605
|
+
let end = safe.length;
|
|
1606
|
+
while (start < end && safe.charCodeAt(start) === HYPHEN_CODE) start += 1;
|
|
1607
|
+
while (end > start && safe.charCodeAt(end - 1) === HYPHEN_CODE) end -= 1;
|
|
1608
|
+
const normalized = safe.slice(start, end);
|
|
1609
|
+
return normalized.length > 0 ? normalized : "baseline";
|
|
1610
|
+
}
|
|
1611
|
+
var HYPHEN_CODE = 45;
|
|
1612
|
+
var DOT_CODE = 46;
|
|
1613
|
+
var UNDERSCORE_CODE = 95;
|
|
1614
|
+
var ZERO_CODE = 48;
|
|
1615
|
+
var NINE_CODE = 57;
|
|
1616
|
+
var UPPER_A_CODE = 65;
|
|
1617
|
+
var UPPER_Z_CODE = 90;
|
|
1618
|
+
var LOWER_A_CODE = 97;
|
|
1619
|
+
var LOWER_Z_CODE = 122;
|
|
1620
|
+
function isSafeBaselineNameChar(char) {
|
|
1621
|
+
if (char.length !== 1) return false;
|
|
1622
|
+
const code = char.charCodeAt(0);
|
|
1623
|
+
return code === HYPHEN_CODE || code === DOT_CODE || code === UNDERSCORE_CODE || code >= ZERO_CODE && code <= NINE_CODE || code >= UPPER_A_CODE && code <= UPPER_Z_CODE || code >= LOWER_A_CODE && code <= LOWER_Z_CODE;
|
|
1624
|
+
}
|
|
1625
|
+
async function pruneHistoryFile(historyFile, maxEntries) {
|
|
1626
|
+
const raw = await readFile(historyFile, "utf8");
|
|
1627
|
+
const lines = raw.split(/\r?\n/).filter((line) => line.trim().length > 0);
|
|
1628
|
+
if (lines.length <= maxEntries) return;
|
|
1629
|
+
await writeFile(historyFile, `${lines.slice(-maxEntries).join("\n")}
|
|
1630
|
+
`, "utf8");
|
|
1631
|
+
}
|
|
1632
|
+
function normalizeThresholds2(thresholds) {
|
|
1633
|
+
return {
|
|
1634
|
+
maxRegressionPercent: positiveNumber2(thresholds.maxRegressionPercent, 10),
|
|
1635
|
+
maxHeapRegressionPercent: positiveNumber2(thresholds.maxHeapRegressionPercent, 25),
|
|
1636
|
+
warnAtRatio: clamp(positiveNumber2(thresholds.warnAtRatio, 0.5), 0, 1),
|
|
1637
|
+
failOnMissingMetric: thresholds.failOnMissingMetric ?? false
|
|
1638
|
+
};
|
|
1639
|
+
}
|
|
1640
|
+
function aggregateRuntimeOps(report) {
|
|
1641
|
+
const units = report.results.reduce((sum2, result) => sum2 + result.units, 0);
|
|
1642
|
+
const durationMs = report.results.reduce((sum2, result) => sum2 + result.durationMs, 0);
|
|
1643
|
+
return round9(units / Math.max(durationMs / 1e3, 1e-3));
|
|
1644
|
+
}
|
|
1645
|
+
function metricKey(metric) {
|
|
1646
|
+
const tags = metric.tags ? Object.entries(metric.tags).sort(([a], [b]) => a.localeCompare(b)).map(([key, value]) => `${key}=${value}`).join(",") : "";
|
|
1647
|
+
return `${metric.name}|${tags}`;
|
|
1648
|
+
}
|
|
1649
|
+
function formatMetricName(metric) {
|
|
1650
|
+
const tags = metric.tags ? Object.entries(metric.tags).sort(([a], [b]) => a.localeCompare(b)).map(([key, value]) => `${key}=${value}`).join(",") : "";
|
|
1651
|
+
return tags.length > 0 ? `${metric.name}{${tags}}` : metric.name;
|
|
1652
|
+
}
|
|
1653
|
+
function isHeapLikeMetric(metric) {
|
|
1654
|
+
return metric.name.includes("heap") || metric.name.includes("rss");
|
|
1655
|
+
}
|
|
1656
|
+
function makeEntryId(profile, timestamp) {
|
|
1657
|
+
return `${profile}-${timestamp.replace(/[^0-9A-Za-z]/g, "")}-${Math.random().toString(36).slice(2, 8)}`;
|
|
1658
|
+
}
|
|
1659
|
+
function isBrassPerformanceReport(value) {
|
|
1660
|
+
return isRecord(value) && ("recorder" in value || "recommendations" in value) && ("runtime" in value || "http" in value || "memory" in value);
|
|
1661
|
+
}
|
|
1662
|
+
function isRuntimePrimitiveProfileReport(value) {
|
|
1663
|
+
return isRecord(value) && Array.isArray(value.results) && typeof value.variant === "string" && typeof value.chainDepth === "number";
|
|
1664
|
+
}
|
|
1665
|
+
function isHttpLayerProfileReport(value) {
|
|
1666
|
+
return isRecord(value) && Array.isArray(value.results) && typeof value.calls === "number" && value.results.some((item) => isRecord(item) && "httpPerSec" in item);
|
|
1667
|
+
}
|
|
1668
|
+
function isRuntimeAbReport(value) {
|
|
1669
|
+
return isRecord(value) && Array.isArray(value.comparisons) && isRuntimePrimitiveProfileReport(value.baseline) && isRuntimePrimitiveProfileReport(value.candidate);
|
|
1670
|
+
}
|
|
1671
|
+
function isRuntimeSoakReport(value) {
|
|
1672
|
+
return isRecord(value) && Array.isArray(value.rounds) && typeof value.throughputTrendPercent === "number" && typeof value.variant === "string";
|
|
1673
|
+
}
|
|
1674
|
+
function isHttpMemoryLabReport(value) {
|
|
1675
|
+
return isRecord(value) && Array.isArray(value.summaries) && Array.isArray(value.roundsData);
|
|
1676
|
+
}
|
|
1677
|
+
function isRecord(value) {
|
|
1678
|
+
return typeof value === "object" && value !== null;
|
|
1679
|
+
}
|
|
1680
|
+
function isNodeError(value) {
|
|
1681
|
+
return value instanceof Error && "code" in value;
|
|
1682
|
+
}
|
|
1683
|
+
function positiveNumber2(value, fallback) {
|
|
1684
|
+
if (value === void 0 || !Number.isFinite(value)) return fallback;
|
|
1685
|
+
return Math.max(0, value);
|
|
1686
|
+
}
|
|
1687
|
+
function percentDelta2(current, baseline) {
|
|
1688
|
+
if (baseline === 0) return current === 0 ? 0 : 100;
|
|
1689
|
+
return (current - baseline) / Math.abs(baseline) * 100;
|
|
1690
|
+
}
|
|
1691
|
+
function clamp(value, min, max) {
|
|
1692
|
+
return Math.min(max, Math.max(min, value));
|
|
1693
|
+
}
|
|
1694
|
+
function round9(value) {
|
|
1695
|
+
return Math.round(value * 1e3) / 1e3;
|
|
1696
|
+
}
|
|
1697
|
+
|
|
1698
|
+
// src/perf/recommendations.ts
|
|
1699
|
+
function recommendPerformance(input) {
|
|
1700
|
+
const thresholds = {
|
|
1701
|
+
maxHeapDeltaMb: input.thresholds?.maxHeapDeltaMb ?? 16,
|
|
1702
|
+
criticalHeapDeltaMb: input.thresholds?.criticalHeapDeltaMb ?? 64,
|
|
1703
|
+
minDefaultVsNodeRatio: input.thresholds?.minDefaultVsNodeRatio ?? 0.7,
|
|
1704
|
+
minObservedVsDefaultRatio: input.thresholds?.minObservedVsDefaultRatio ?? 0.7,
|
|
1705
|
+
maxHttpP99Ms: input.thresholds?.maxHttpP99Ms ?? Math.max(250, (input.http?.delayMs ?? 1) * 80),
|
|
1706
|
+
minRuntimeOpsPerSecond: input.thresholds?.minRuntimeOpsPerSecond ?? 25e3
|
|
1707
|
+
};
|
|
1708
|
+
const out = [];
|
|
1709
|
+
addHttpRecommendations(out, input.http, thresholds);
|
|
1710
|
+
addRuntimeRecommendations(out, input.runtime, thresholds);
|
|
1711
|
+
addMemoryRecommendations(out, input.memoryDelta, thresholds);
|
|
1712
|
+
if (out.length === 0) {
|
|
1713
|
+
out.push({
|
|
1714
|
+
severity: "info",
|
|
1715
|
+
area: "benchmark",
|
|
1716
|
+
title: "No obvious performance regression",
|
|
1717
|
+
message: "The sampled runtime, HTTP, and memory signals are inside the default profiler thresholds.",
|
|
1718
|
+
action: "Keep this report as a baseline and compare it after larger runtime or HTTP changes."
|
|
1719
|
+
});
|
|
1720
|
+
}
|
|
1721
|
+
return Object.freeze(out.map((item) => Object.freeze(item)));
|
|
1722
|
+
}
|
|
1723
|
+
function addHttpRecommendations(out, report, thresholds) {
|
|
1724
|
+
if (!report) return;
|
|
1725
|
+
const node = findResult(report, "node-http-text");
|
|
1726
|
+
const def = findResult(report, "default-json");
|
|
1727
|
+
const observed = findResult(report, "default-json-observed");
|
|
1728
|
+
for (const result of report.results) {
|
|
1729
|
+
if (result.errorCount > 0) {
|
|
1730
|
+
out.push({
|
|
1731
|
+
severity: "critical",
|
|
1732
|
+
area: "http",
|
|
1733
|
+
title: `HTTP errors in ${result.variant}`,
|
|
1734
|
+
message: `${result.errorCount} of ${result.calls} profiled requests failed.`,
|
|
1735
|
+
evidence: {
|
|
1736
|
+
variant: result.variant,
|
|
1737
|
+
firstError: result.firstError,
|
|
1738
|
+
successCount: result.successCount,
|
|
1739
|
+
errorCount: result.errorCount
|
|
1740
|
+
},
|
|
1741
|
+
action: "Inspect timeout, pool, queue, retry, and server-side pressure before trusting throughput numbers."
|
|
1742
|
+
});
|
|
1743
|
+
}
|
|
1744
|
+
if (result.memory.delta.heapUsedMb >= thresholds.criticalHeapDeltaMb) {
|
|
1745
|
+
out.push(memoryRecommendation("critical", result));
|
|
1746
|
+
} else if (result.memory.delta.heapUsedMb >= thresholds.maxHeapDeltaMb) {
|
|
1747
|
+
out.push(memoryRecommendation("warn", result));
|
|
1748
|
+
}
|
|
1749
|
+
if (result.requestP99Ms > thresholds.maxHttpP99Ms) {
|
|
1750
|
+
out.push({
|
|
1751
|
+
severity: "warn",
|
|
1752
|
+
area: "http",
|
|
1753
|
+
title: `High p99 latency in ${result.variant}`,
|
|
1754
|
+
message: `p99 was ${result.requestP99Ms}ms against a ${report.delayMs}ms local server delay.`,
|
|
1755
|
+
evidence: {
|
|
1756
|
+
variant: result.variant,
|
|
1757
|
+
p50Ms: result.requestP50Ms,
|
|
1758
|
+
p95Ms: result.requestP95Ms,
|
|
1759
|
+
p99Ms: result.requestP99Ms,
|
|
1760
|
+
thresholdMs: thresholds.maxHttpP99Ms
|
|
1761
|
+
},
|
|
1762
|
+
action: "Compare queue depth, adaptive limiter state, and observability overhead for this variant."
|
|
1763
|
+
});
|
|
1764
|
+
}
|
|
1765
|
+
if ((result.lifecycleMaxQueueDepth ?? 0) > 0 || (result.clientPoolMaxQueued ?? 0) > 0) {
|
|
1766
|
+
out.push({
|
|
1767
|
+
severity: "info",
|
|
1768
|
+
area: "http",
|
|
1769
|
+
title: `Queueing observed in ${result.variant}`,
|
|
1770
|
+
message: "The client queued work during the run, so throughput is bounded by a limiter or pool setting.",
|
|
1771
|
+
evidence: {
|
|
1772
|
+
variant: result.variant,
|
|
1773
|
+
lifecycleMaxQueueDepth: result.lifecycleMaxQueueDepth,
|
|
1774
|
+
clientPoolMaxQueued: result.clientPoolMaxQueued,
|
|
1775
|
+
concurrency: result.concurrency
|
|
1776
|
+
},
|
|
1777
|
+
action: "Tune priority concurrency, pool concurrency, or adaptive limiter bounds for this workload."
|
|
1778
|
+
});
|
|
1779
|
+
}
|
|
1780
|
+
if ((result.adaptiveMaxQueueDepth ?? 0) > 0 && (result.adaptiveFinalLimit ?? result.concurrency) < result.concurrency / 4) {
|
|
1781
|
+
out.push({
|
|
1782
|
+
severity: "warn",
|
|
1783
|
+
area: "http",
|
|
1784
|
+
title: `Adaptive limiter constrained ${result.variant}`,
|
|
1785
|
+
message: "The adaptive limiter ended far below requested concurrency while requests queued.",
|
|
1786
|
+
evidence: {
|
|
1787
|
+
variant: result.variant,
|
|
1788
|
+
adaptiveFinalLimit: result.adaptiveFinalLimit,
|
|
1789
|
+
adaptiveMaxQueueDepth: result.adaptiveMaxQueueDepth,
|
|
1790
|
+
concurrency: result.concurrency,
|
|
1791
|
+
adaptiveFinalGradient: result.adaptiveFinalGradient
|
|
1792
|
+
},
|
|
1793
|
+
action: "Try a warmer initial limit, more warmup calls, or a more aggressive preset for short local workloads."
|
|
1794
|
+
});
|
|
1795
|
+
}
|
|
1796
|
+
}
|
|
1797
|
+
if (node && def) {
|
|
1798
|
+
const ratio = ratioOf(def.httpPerSec, node.httpPerSec);
|
|
1799
|
+
if (ratio < thresholds.minDefaultVsNodeRatio) {
|
|
1800
|
+
out.push({
|
|
1801
|
+
severity: "warn",
|
|
1802
|
+
area: "http",
|
|
1803
|
+
title: "Default HTTP overhead is visible",
|
|
1804
|
+
message: `Default JSON throughput is ${(ratio * 100).toFixed(1)}% of the node:http baseline.`,
|
|
1805
|
+
evidence: {
|
|
1806
|
+
nodeHttpPerSec: node.httpPerSec,
|
|
1807
|
+
defaultHttpPerSec: def.httpPerSec,
|
|
1808
|
+
ratio
|
|
1809
|
+
},
|
|
1810
|
+
action: "Run the focused HTTP profile with GC enabled and compare minimal, balanced, default, and observed variants."
|
|
1811
|
+
});
|
|
1812
|
+
}
|
|
1813
|
+
}
|
|
1814
|
+
if (def && observed) {
|
|
1815
|
+
const ratio = ratioOf(observed.httpPerSec, def.httpPerSec);
|
|
1816
|
+
if (ratio < thresholds.minObservedVsDefaultRatio) {
|
|
1817
|
+
out.push({
|
|
1818
|
+
severity: "warn",
|
|
1819
|
+
area: "observability",
|
|
1820
|
+
title: "Observability overhead is visible",
|
|
1821
|
+
message: `Observed default throughput is ${(ratio * 100).toFixed(1)}% of the unobserved default client.`,
|
|
1822
|
+
evidence: {
|
|
1823
|
+
defaultHttpPerSec: def.httpPerSec,
|
|
1824
|
+
observedHttpPerSec: observed.httpPerSec,
|
|
1825
|
+
ratio,
|
|
1826
|
+
observedFinishedSpans: observed.observedFinishedSpans
|
|
1827
|
+
},
|
|
1828
|
+
action: "Check span retention, attribute cardinality, trace sampling, and exporter flush strategy."
|
|
1829
|
+
});
|
|
1830
|
+
}
|
|
1831
|
+
}
|
|
1832
|
+
}
|
|
1833
|
+
function addRuntimeRecommendations(out, report, thresholds) {
|
|
1834
|
+
if (!report) return;
|
|
1835
|
+
for (const result of report.results) {
|
|
1836
|
+
if (result.units >= 1e3 && result.operationsPerSecond < thresholds.minRuntimeOpsPerSecond) {
|
|
1837
|
+
out.push({
|
|
1838
|
+
severity: "info",
|
|
1839
|
+
area: "runtime",
|
|
1840
|
+
title: `Low sampled runtime throughput for ${result.name}`,
|
|
1841
|
+
message: `${result.name} measured ${result.operationsPerSecond} ${result.unit}/s in this local profile.`,
|
|
1842
|
+
evidence: {
|
|
1843
|
+
primitive: result.name,
|
|
1844
|
+
operationsPerSecond: result.operationsPerSecond,
|
|
1845
|
+
threshold: thresholds.minRuntimeOpsPerSecond,
|
|
1846
|
+
units: result.units,
|
|
1847
|
+
durationMs: result.durationMs
|
|
1848
|
+
},
|
|
1849
|
+
action: "Use npm run benchmark:runtime for the stable regression budget before changing interpreter internals."
|
|
1850
|
+
});
|
|
1851
|
+
}
|
|
1852
|
+
}
|
|
1853
|
+
}
|
|
1854
|
+
function addMemoryRecommendations(out, delta2, thresholds) {
|
|
1855
|
+
if (!delta2) return;
|
|
1856
|
+
if (delta2.heapUsedMb >= thresholds.criticalHeapDeltaMb) {
|
|
1857
|
+
out.push({
|
|
1858
|
+
severity: "critical",
|
|
1859
|
+
area: "memory",
|
|
1860
|
+
title: "Large retained heap in full profile",
|
|
1861
|
+
message: `The whole profiler run retained ${delta2.heapUsedMb}MB of heap.`,
|
|
1862
|
+
evidence: delta2,
|
|
1863
|
+
action: "Re-run with node --expose-gc and isolate runtime-only vs HTTP-only profiles."
|
|
1864
|
+
});
|
|
1865
|
+
} else if (delta2.heapUsedMb >= thresholds.maxHeapDeltaMb) {
|
|
1866
|
+
out.push({
|
|
1867
|
+
severity: "warn",
|
|
1868
|
+
area: "memory",
|
|
1869
|
+
title: "Retained heap deserves a focused run",
|
|
1870
|
+
message: `The whole profiler run retained ${delta2.heapUsedMb}MB of heap.`,
|
|
1871
|
+
evidence: delta2,
|
|
1872
|
+
action: "Compare forceGc=false and forceGc=true to separate allocator churn from retained references."
|
|
1873
|
+
});
|
|
1874
|
+
}
|
|
1875
|
+
}
|
|
1876
|
+
function memoryRecommendation(severity, result) {
|
|
1877
|
+
return {
|
|
1878
|
+
severity,
|
|
1879
|
+
area: "memory",
|
|
1880
|
+
title: `HTTP heap retention in ${result.variant}`,
|
|
1881
|
+
message: `${result.variant} retained ${result.memory.delta.heapUsedMb}MB of heap during the sampled run.`,
|
|
1882
|
+
evidence: {
|
|
1883
|
+
variant: result.variant,
|
|
1884
|
+
heapDeltaMb: result.memory.delta.heapUsedMb,
|
|
1885
|
+
rssDeltaMb: result.memory.delta.rssMb,
|
|
1886
|
+
gcAvailable: result.gcAvailable
|
|
1887
|
+
},
|
|
1888
|
+
action: "Run with node --expose-gc, increase calls, and compare with/without observability."
|
|
1889
|
+
};
|
|
1890
|
+
}
|
|
1891
|
+
function findResult(report, variant) {
|
|
1892
|
+
return report.results.find((result) => result.variant === variant);
|
|
1893
|
+
}
|
|
1894
|
+
function ratioOf(numerator, denominator) {
|
|
1895
|
+
if (denominator <= 0) return 1;
|
|
1896
|
+
return numerator / denominator;
|
|
1897
|
+
}
|
|
1898
|
+
|
|
1899
|
+
// src/perf/report.ts
|
|
1900
|
+
async function runBrassPerformanceProfile(options = {}) {
|
|
1901
|
+
const recorder = resolveRecorder(options.recorder);
|
|
1902
|
+
const memoryOptions = options.memory === false ? void 0 : options.memory ?? {};
|
|
1903
|
+
const before = memoryOptions ? captureMemorySnapshot(memoryOptions) : void 0;
|
|
1904
|
+
const runtime = options.runtime === false ? void 0 : await recorder.measureAsync(
|
|
1905
|
+
"perf.runtime",
|
|
1906
|
+
() => profileRuntimePrimitives({ ...options.runtime ?? {}, recorder })
|
|
1907
|
+
);
|
|
1908
|
+
const http = options.http === false ? void 0 : await recorder.measureAsync(
|
|
1909
|
+
"perf.http",
|
|
1910
|
+
() => profileHttpLayers({ ...options.http ?? {}, recorder })
|
|
1911
|
+
);
|
|
1912
|
+
const after = memoryOptions ? captureMemorySnapshot(memoryOptions) : void 0;
|
|
1913
|
+
const memory = before && after ? Object.freeze({
|
|
1914
|
+
before,
|
|
1915
|
+
after,
|
|
1916
|
+
delta: diffMemorySnapshots(before, after)
|
|
1917
|
+
}) : void 0;
|
|
1918
|
+
const runtimeDiagnostics = runtime ? diagnoseRuntimeProfile(runtime) : void 0;
|
|
1919
|
+
const recommendations = recommendPerformance({
|
|
1920
|
+
runtime,
|
|
1921
|
+
http,
|
|
1922
|
+
memoryDelta: memory?.delta,
|
|
1923
|
+
thresholds: options.thresholds
|
|
1924
|
+
});
|
|
1925
|
+
return Object.freeze({
|
|
1926
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1927
|
+
nodeVersion: process.version,
|
|
1928
|
+
platform: process.platform,
|
|
1929
|
+
arch: process.arch,
|
|
1930
|
+
runtime,
|
|
1931
|
+
runtimeDiagnostics,
|
|
1932
|
+
http,
|
|
1933
|
+
memory,
|
|
1934
|
+
recorder: Object.freeze({
|
|
1935
|
+
stats: recorder.stats(),
|
|
1936
|
+
summary: recorder.explain()
|
|
1937
|
+
}),
|
|
1938
|
+
recommendations
|
|
1939
|
+
});
|
|
1940
|
+
}
|
|
1941
|
+
function formatPerformanceReport(report) {
|
|
1942
|
+
const lines = [];
|
|
1943
|
+
lines.push("Brass Performance Profile");
|
|
1944
|
+
lines.push(`timestamp=${report.timestamp} node=${report.nodeVersion} platform=${report.platform}/${report.arch}`);
|
|
1945
|
+
if (report.runtime) {
|
|
1946
|
+
lines.push("");
|
|
1947
|
+
lines.push(`Runtime primitives (${report.runtime.variant})`);
|
|
1948
|
+
for (const result of report.runtime.results) {
|
|
1949
|
+
lines.push(`- ${result.name}: ${formatNumber4(result.operationsPerSecond)} ${result.unit}/s, ns/op=${result.nsPerOperation}, fibers=${result.fibersStarted}`);
|
|
1950
|
+
}
|
|
1951
|
+
}
|
|
1952
|
+
if (report.runtimeDiagnostics) {
|
|
1953
|
+
lines.push("");
|
|
1954
|
+
lines.push("Runtime diagnostics");
|
|
1955
|
+
lines.push(`- hottest: ${report.runtimeDiagnostics.slowest.name} (${report.runtimeDiagnostics.slowest.nsPerOperation} ns/op)`);
|
|
1956
|
+
lines.push(`- fibers: ${report.runtimeDiagnostics.totalFibersStarted} total, ${report.runtimeDiagnostics.averageFibersPerThousandOps}/1k ops`);
|
|
1957
|
+
for (const note of report.runtimeDiagnostics.notes) lines.push(`- ${note}`);
|
|
1958
|
+
}
|
|
1959
|
+
if (report.http) {
|
|
1960
|
+
lines.push("");
|
|
1961
|
+
lines.push(`HTTP layers (${report.http.calls} calls, concurrency=${report.http.concurrency}, delay=${report.http.delayMs}ms)`);
|
|
1962
|
+
for (const result of report.http.results) {
|
|
1963
|
+
const heap = result.memory.delta.heapUsedMb;
|
|
1964
|
+
const p99 = result.requestP99Ms;
|
|
1965
|
+
lines.push(`- ${result.variant}: ${formatNumber4(result.httpPerSec)} http/s, p99=${p99}ms, heapDelta=${heap}MB, errors=${result.errorCount}`);
|
|
1966
|
+
}
|
|
1967
|
+
}
|
|
1968
|
+
if (report.memory) {
|
|
1969
|
+
lines.push("");
|
|
1970
|
+
lines.push(`Memory: heapDelta=${report.memory.delta.heapUsedMb}MB rssDelta=${report.memory.delta.rssMb}MB gc=${report.memory.after.gcAvailable ? "available" : "unavailable"}`);
|
|
1971
|
+
}
|
|
1972
|
+
lines.push("");
|
|
1973
|
+
lines.push("Recommendations");
|
|
1974
|
+
for (const item of report.recommendations) {
|
|
1975
|
+
lines.push(`- [${item.severity}] ${item.area}: ${item.title}`);
|
|
1976
|
+
lines.push(` ${item.message}`);
|
|
1977
|
+
if (item.action) lines.push(` action: ${item.action}`);
|
|
1978
|
+
}
|
|
1979
|
+
return lines.join("\n");
|
|
1980
|
+
}
|
|
1981
|
+
function resolveRecorder(input) {
|
|
1982
|
+
if (isPerfRecorder(input)) return input;
|
|
1983
|
+
return makePerfRecorder(input);
|
|
1984
|
+
}
|
|
1985
|
+
function isPerfRecorder(input) {
|
|
1986
|
+
return Boolean(input && "record" in input && typeof input.record === "function");
|
|
1987
|
+
}
|
|
1988
|
+
function formatNumber4(value) {
|
|
1989
|
+
return value >= 1e3 ? value.toLocaleString("en-US", { maximumFractionDigits: 0 }) : value.toFixed(3);
|
|
1990
|
+
}
|
|
1991
|
+
|
|
1992
|
+
export {
|
|
1993
|
+
makePerfRecorder,
|
|
1994
|
+
summarizePerfEvents,
|
|
1995
|
+
hasGc,
|
|
1996
|
+
forceGc,
|
|
1997
|
+
captureMemorySnapshot,
|
|
1998
|
+
diffMemorySnapshots,
|
|
1999
|
+
profileMemoryRetention,
|
|
2000
|
+
profileRuntimePrimitives,
|
|
2001
|
+
diagnoseRuntimeProfile,
|
|
2002
|
+
profileRuntimeAb,
|
|
2003
|
+
compareRuntimeProfiles,
|
|
2004
|
+
formatRuntimeAbReport,
|
|
2005
|
+
profileRuntimeSoak,
|
|
2006
|
+
formatRuntimeSoakReport,
|
|
2007
|
+
HTTP_PROFILE_VARIANTS,
|
|
2008
|
+
profileHttpLayers,
|
|
2009
|
+
profileHttpMemoryLab,
|
|
2010
|
+
formatHttpMemoryLabReport,
|
|
2011
|
+
defaultPerfHistoryDirectory,
|
|
2012
|
+
createPerfHistoryEntry,
|
|
2013
|
+
recordPerfHistoryRun,
|
|
2014
|
+
writePerfHistoryEntry,
|
|
2015
|
+
readPerfHistory,
|
|
2016
|
+
savePerfBaseline,
|
|
2017
|
+
loadPerfBaseline,
|
|
2018
|
+
comparePerfToBaseline,
|
|
2019
|
+
formatPerfBaselineComparison,
|
|
2020
|
+
extractPerfMetrics,
|
|
2021
|
+
recommendPerformance,
|
|
2022
|
+
runBrassPerformanceProfile,
|
|
2023
|
+
formatPerformanceReport
|
|
2024
|
+
};
|