Skip to content

Commit 5af276e

Browse files
committed
perf_hooks: add histogram export format version 2
Histograms exported by Node.js v26.9.0 use format version 1, which rejects unknown keys on import. Adding fields to version 1 data would therefore break importing it in v26.9.0, even though that release claims to support version 1. Introduce format version 2. It has the same layout as version 1, but unknown keys are ignored on import, so fields can be added to it later without changing the version again, while older releases reject it based on the version rather than on the new fields. `histogram.export()` now produces version 2 data. `importHistogram()` accepts versions 1 and 2, and imports version 1 data, as well as data without a version, with the original semantics. Assisted-by: OpenCode Signed-off-by: James M Snell <jasnell@gmail.com> PR-URL: #66098 Reviewed-By: Xuguang Mei <meixuguang@gmail.com>
1 parent f916641 commit 5af276e

3 files changed

Lines changed: 266 additions & 8 deletions

File tree

‎doc/api/perf_hooks.md‎

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1816,6 +1816,11 @@ console.log(snapshot.percentile(99));
18161816

18171817
<!-- YAML
18181818
added: v26.9.0
1819+
changes:
1820+
- version: REPLACEME
1821+
pr-url: https://github.com/nodejs/node/pull/66098
1822+
description: Format version 2 is supported. Unknown keys in version 2
1823+
data are ignored.
18191824
-->
18201825

18211826
* `data` {Uint8Array} A CBOR-encoded histogram previously produced by
@@ -1826,6 +1831,9 @@ Reconstructs a histogram from a CBOR-encoded `Uint8Array`. The returned
18261831
histogram is a full {RecordableHistogram} with all bucket data, configuration,
18271832
and EWMA state restored. New values can be recorded into it.
18281833

1834+
Data in any format version produced by [`histogram.export()`][] can be
1835+
imported. See [histogram export format compatibility][] for details.
1836+
18291837
```js
18301838
const { createHistogram, importHistogram } = require('node:perf_hooks');
18311839

@@ -2219,6 +2227,10 @@ loop delay threshold.
22192227

22202228
<!-- YAML
22212229
added: v26.9.0
2230+
changes:
2231+
- version: REPLACEME
2232+
pr-url: https://github.com/nodejs/node/pull/66098
2233+
description: The output uses format version 2.
22222234
-->
22232235

22242236
* Returns: {Uint8Array}
@@ -2237,7 +2249,7 @@ The CBOR payload is a map with integer keys:
22372249

22382250
| Key | Type | Field |
22392251
| --- | ------- | --------------------------------------------- |
2240-
| 0 | uint | Format version (currently 1) |
2252+
| 0 | uint | Format version (currently 2) |
22412253
| 1 | uint | Lowest discernible value |
22422254
| 2 | uint | Highest trackable value |
22432255
| 3 | uint | Significant figures |
@@ -2252,6 +2264,23 @@ The CBOR payload is a map with integer keys:
22522264

22532265
Any standard CBOR decoder can parse the output.
22542266

2267+
#### Histogram export format compatibility
2268+
2269+
[`perf_hooks.importHistogram()`][] accepts every format version that
2270+
`histogram.export()` has produced:
2271+
2272+
* Version 1 was produced by Node.js v26.9.0. Data with a version 1 key, or
2273+
without a version key, is imported with the original semantics: keys
2274+
that are not listed above are rejected.
2275+
* Version 2 has the same layout as version 1. Keys that are not recognized
2276+
are ignored, so later versions of Node.js can add fields to version 2
2277+
data without changing the version, and the data remains importable.
2278+
2279+
Data with any other version is rejected.
2280+
2281+
When the total count, min, or max value is absent, it is derived from the
2282+
bucket counts. A total count that is present must match the bucket counts.
2283+
22552284
### `histogram.ewmaMean`
22562285

22572286
<!-- YAML
@@ -3308,3 +3337,4 @@ dns.promises.resolve('localhost');
33083337
[`timeOrigin`]: https://w3c.github.io/hr-time/#dom-performance-timeorigin
33093338
[`window.performance.toJSON`]: https://developer.mozilla.org/en-US/docs/Web/API/Performance/toJSON
33103339
[`window.performance`]: https://developer.mozilla.org/en-US/docs/Web/API/Window/performance
3340+
[histogram export format compatibility]: #histogram-export-format-compatibility

‎src/histogram.cc‎

Lines changed: 91 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1109,8 +1109,66 @@ static bool CborReadNumber(const uint8_t*& p, const uint8_t* end, double* val) {
11091109
return true;
11101110
}
11111111

1112-
// Histogram export format version.
1113-
constexpr uint64_t kExportVersion = 1;
1112+
// Maximum nesting depth of the values that CborSkipItem() skips over.
1113+
constexpr int kCborMaxSkipDepth = 16;
1114+
1115+
// Skip over one well-formed data item, including any items nested within it.
1116+
// Indefinite-length items are not supported.
1117+
static bool CborSkipItem(const uint8_t*& p, const uint8_t* end, int depth = 0) {
1118+
if (p >= end || depth > kCborMaxSkipDepth) return false;
1119+
const uint8_t major = *p >> 5;
1120+
const uint8_t info = *p & 0x1f;
1121+
1122+
if (major == 7) {
1123+
// Simple values and floats. Additional information 24 to 27 is followed
1124+
// by 1, 2, 4, or 8 bytes; 28 to 30 are reserved, and 31 is a break.
1125+
size_t extra;
1126+
if (info <= 23) {
1127+
extra = 0;
1128+
} else if (info <= 27) {
1129+
extra = size_t{1} << (info - 24);
1130+
} else {
1131+
return false;
1132+
}
1133+
p++;
1134+
if (static_cast<size_t>(end - p) < extra) return false;
1135+
p += extra;
1136+
return true;
1137+
}
1138+
1139+
uint64_t arg;
1140+
if (!CborReadUint(p, end, &arg)) return false;
1141+
switch (major) {
1142+
case 0: // Unsigned integer.
1143+
case 1: // Negative integer.
1144+
return true;
1145+
case 2: // Byte string.
1146+
case 3: // Text string.
1147+
if (arg > static_cast<uint64_t>(end - p)) return false;
1148+
p += arg;
1149+
return true;
1150+
case 4: // Array.
1151+
case 5: { // Map.
1152+
// Each item takes at least one byte, which also bounds the loop.
1153+
if (arg > static_cast<uint64_t>(end - p)) return false;
1154+
const uint64_t items = major == 4 ? arg : arg * 2;
1155+
for (uint64_t i = 0; i < items; i++) {
1156+
if (!CborSkipItem(p, end, depth + 1)) return false;
1157+
}
1158+
return true;
1159+
}
1160+
case 6: // Tag.
1161+
return CborSkipItem(p, end, depth + 1);
1162+
}
1163+
return false;
1164+
}
1165+
1166+
// Histogram export format version. Version 2 has the same layout as
1167+
// version 1, but importers ignore unknown keys in version 2 data, so fields
1168+
// can be added without changing the version. Version 1 data is imported
1169+
// with its original semantics, which reject unknown keys.
1170+
constexpr uint64_t kExportVersion = 2;
1171+
constexpr uint64_t kStrictExportVersion = 1;
11141172

11151173
// Integer keys for the top-level CBOR map.
11161174
constexpr uint64_t kKeyVersion = 0;
@@ -1423,7 +1481,7 @@ Histogram::PercentileCIResult Histogram::PercentileCI(double percentile,
14231481
// common case.
14241482
//
14251483
// Layout: a CBOR map with integer keys:
1426-
// 0 -> uint format version (currently 1)
1484+
// 0 -> uint format version (currently 2)
14271485
// 1 -> uint lowest discernible value
14281486
// 2 -> uint highest trackable value
14291487
// 3 -> uint significant figures
@@ -1441,6 +1499,13 @@ Histogram::PercentileCIResult Histogram::PercentileCI(double percentile,
14411499
// 2 -> float64 variance
14421500
// 3 -> float64 error rate
14431501
// 4 -> uint threshold
1502+
//
1503+
// Compatibility: Import() accepts format versions 1 and 2, whose layouts are
1504+
// identical. In version 2 data, keys that the importer does not recognize are
1505+
// skipped, so new fields can be added without changing the version. Version 1
1506+
// data keeps its original semantics, in which unknown keys are rejected. Any
1507+
// field may be absent; the total count, min, and max are then derived from
1508+
// the counts.
14441509
std::vector<uint8_t> Histogram::Export() const {
14451510
RwLock::ScopedReadLock lock(mutex_);
14461511

@@ -1547,12 +1612,16 @@ std::shared_ptr<Histogram> Histogram::Import(const uint8_t* data, size_t len) {
15471612
int32_t norm_offset = 0;
15481613
double conv_ratio = 1.0;
15491614
int32_t counts_len = 0;
1550-
uint64_t version = 0;
1615+
// Data without a version key is imported as version 1.
1616+
uint64_t version = kStrictExportVersion;
15511617

15521618
// Bitsets of the keys read so far, used to reject duplicate keys and to
15531619
// tell whether a field was present. All known keys are less than 64.
15541620
uint64_t seen_keys = 0;
15551621
uint64_t seen_ewma_keys = 0;
1622+
// Unknown keys are skipped while parsing, because the version key that
1623+
// determines whether they are allowed can appear anywhere in the map.
1624+
bool has_unknown_keys = false;
15561625
auto mark_seen = [](uint64_t* seen, uint64_t key) {
15571626
const uint64_t bit = uint64_t{1} << key;
15581627
if (*seen & bit) return false;
@@ -1577,13 +1646,20 @@ std::shared_ptr<Histogram> Histogram::Import(const uint8_t* data, size_t len) {
15771646
// Read key (unsigned int).
15781647
uint64_t key;
15791648
if (!CborReadArgument(p, end, kCborUint, &key)) return nullptr;
1580-
if (key > kKeyEwma) return nullptr; // Unknown key.
1649+
if (key > kKeyEwma) {
1650+
// Unknown key.
1651+
if (!CborSkipItem(p, end)) return nullptr;
1652+
has_unknown_keys = true;
1653+
continue;
1654+
}
15811655
if (!mark_seen(&seen_keys, key)) return nullptr; // Duplicate key.
15821656

15831657
switch (key) {
15841658
case kKeyVersion:
15851659
if (!CborReadArgument(p, end, kCborUint, &version)) return nullptr;
1586-
if (version != kExportVersion) return nullptr;
1660+
if (version < kStrictExportVersion || version > kExportVersion) {
1661+
return nullptr;
1662+
}
15871663
break;
15881664
case kKeyLowest:
15891665
if (!CborReadInt64(p, end, &lowest)) return nullptr;
@@ -1648,7 +1724,12 @@ std::shared_ptr<Histogram> Histogram::Import(const uint8_t* data, size_t len) {
16481724
for (uint64_t j = 0; j < sub_size; j++) {
16491725
uint64_t sub_key;
16501726
if (!CborReadArgument(p, end, kCborUint, &sub_key)) return nullptr;
1651-
if (sub_key > kEwmaThreshold) return nullptr; // Unknown EWMA key.
1727+
if (sub_key > kEwmaThreshold) {
1728+
// Unknown EWMA key.
1729+
if (!CborSkipItem(p, end)) return nullptr;
1730+
has_unknown_keys = true;
1731+
continue;
1732+
}
16521733
if (!mark_seen(&seen_ewma_keys, sub_key)) return nullptr;
16531734
switch (sub_key) {
16541735
case kEwmaAlpha:
@@ -1673,6 +1754,9 @@ std::shared_ptr<Histogram> Histogram::Import(const uint8_t* data, size_t len) {
16731754
}
16741755
}
16751756

1757+
// Version 1 data keeps its original semantics: unknown keys are rejected.
1758+
if (version == kStrictExportVersion && has_unknown_keys) return nullptr;
1759+
16761760
// Reconstruct the histogram.
16771761
Options opts;
16781762
opts.lowest = lowest;

‎test/parallel/test-perf-hooks-histogram-import.js‎

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,3 +139,147 @@ assert.throws(() => importBytes(map([
139139
assert.throws(() => importBytes(map([...kLayout, counts(5, 2, 0, 1)])),
140140
kInvalid);
141141
}
142+
143+
// --- Format versions ---
144+
145+
function assertSameHistogram(actual, expected) {
146+
assert.strictEqual(actual.count, expected.count);
147+
assert.strictEqual(actual.min, expected.min);
148+
assert.strictEqual(actual.max, expected.max);
149+
assert.strictEqual(actual.percentile(50), expected.percentile(50));
150+
assert.strictEqual(actual.percentile(100), expected.percentile(100));
151+
}
152+
153+
{
154+
// Version 1 data, as produced by Node.js v26.9.0, remains importable.
155+
const v1 = Buffer.from(
156+
'ac00010101021b001fffffffffffff030304070501061a075bcd15070008fb3f' +
157+
'f00000000000000919b0000a8c010101010101190bfd02191fa101191bba010b' +
158+
'a500fb3fc45d819a94b14c01fb4172dc620791dc2002fb431ce79e5650942003' +
159+
'fb3fd2bec33301886804191388', 'hex');
160+
const h = importHistogram(new Uint8Array(v1));
161+
assert.strictEqual(h.count, 7);
162+
assert.strictEqual(h.min, 1);
163+
assert.strictEqual(h.max, 123469823);
164+
assert.strictEqual(h.mean, 17777921.42857143);
165+
assert.strictEqual(h.stddev, 43136537.01467338);
166+
assert.strictEqual(h.percentile(50), 4099);
167+
assert.strictEqual(h.percentile(100), 123469823);
168+
assert.strictEqual(h.ewmaMean, 19777056.47311032);
169+
assert.strictEqual(h.ewmaStddev, 45099796.52633914);
170+
assert.strictEqual(h.ewmaErrorRate, 0.29289321881345254);
171+
}
172+
173+
{
174+
// export() produces version 2 data, which round-trips.
175+
const h = createHistogram({ halfLife: 4, threshold: 5000 });
176+
for (const value of [1, 2, 3, 4096, 4097, 1000000, 123456789]) {
177+
h.record(value);
178+
}
179+
const data = h.export();
180+
// The first map entry is the version.
181+
assert.deepStrictEqual([...data.subarray(1, 3)], [...uint(0), ...uint(2)]);
182+
const h2 = importHistogram(data);
183+
assertSameHistogram(h2, h);
184+
assert.strictEqual(h2.ewmaMean, h.ewmaMean);
185+
assert.strictEqual(h2.ewmaErrorRate, h.ewmaErrorRate);
186+
}
187+
188+
{
189+
// Unknown keys in version 2 data are ignored, whatever their values are.
190+
const unknownValues = [
191+
uint(2n ** 40n),
192+
negint(7),
193+
[...head(2, 3), 1, 2, 3], // Byte string.
194+
text('hello'),
195+
array([uint(1), array([text('nested')])]),
196+
map([[text('a'), uint(1)], [uint(99), map([[uint(1), f64(2.5)]])]]),
197+
[...head(6, 1), ...text('2026-09-17')], // Tag.
198+
f64(1.5),
199+
[0xf9, 0x3e, 0x00], // Float16.
200+
[0xfa, 0x3f, 0xc0, 0x00, 0x00], // Float32.
201+
[0xf5], // true
202+
[0xf6], // null
203+
[0xf8, 0xff], // Simple value 255.
204+
];
205+
const expected = importBytes(
206+
map([[uint(0), uint(2)], ...kLayout, counts(5, 2, 3, 1)]));
207+
const h = importBytes(map([
208+
[uint(0), uint(2)],
209+
...kLayout,
210+
...unknownValues.map((value, n) => [uint(12 + n), value]),
211+
counts(5, 2, 3, 1),
212+
]));
213+
assertSameHistogram(h, expected);
214+
215+
// Unknown keys may appear before the version key.
216+
assertSameHistogram(importBytes(map([
217+
[uint(12), text('before the version')],
218+
[uint(0), uint(2)],
219+
...kLayout,
220+
counts(5, 2, 3, 1),
221+
])), expected);
222+
223+
// Unknown keys in the EWMA state are ignored as well.
224+
const withEwma = importBytes(map([
225+
[uint(0), uint(2)],
226+
...kLayout,
227+
counts(5, 2, 3, 1),
228+
[uint(11), map([
229+
[uint(0), f64(0.5)],
230+
[uint(99), text('unknown')],
231+
[uint(1), f64(6)],
232+
])],
233+
]));
234+
assertSameHistogram(withEwma, expected);
235+
assert.strictEqual(withEwma.ewmaMean, 6);
236+
}
237+
238+
{
239+
// Version 1 data, or data without a version, keeps the original semantics:
240+
// unknown keys are rejected.
241+
const unknown = [uint(12), uint(0)];
242+
assert.throws(() => importBytes(map([[uint(0), uint(1)], ...kLayout, unknown])),
243+
kInvalid);
244+
assert.throws(() => importBytes(map([unknown, [uint(0), uint(1)], ...kLayout])),
245+
kInvalid);
246+
assert.throws(() => importBytes(map([...kLayout, unknown])), kInvalid);
247+
assert.throws(() => importBytes(map([
248+
[uint(0), uint(1)],
249+
...kLayout,
250+
[uint(11), map([[uint(99), uint(0)]])],
251+
])), kInvalid);
252+
}
253+
254+
// Other versions are rejected.
255+
for (const version of [0, 3, 99]) {
256+
assert.throws(
257+
() => importBytes(map([[uint(0), uint(version)], ...kLayout])),
258+
kInvalid);
259+
}
260+
261+
{
262+
// Values of unknown keys must still be well-formed.
263+
const withUnknownValue =
264+
(value) => map([[uint(0), uint(2)], ...kLayout, [uint(12), value]]);
265+
266+
// Indefinite-length items are not supported.
267+
assert.throws(() => importBytes(withUnknownValue([0x5f, 0x41, 0x00, 0xff])),
268+
kInvalid);
269+
assert.throws(() => importBytes(withUnknownValue([0x9f, 0x00, 0xff])),
270+
kInvalid);
271+
// Reserved additional information and break codes.
272+
assert.throws(() => importBytes(withUnknownValue([0x1c])), kInvalid);
273+
assert.throws(() => importBytes(withUnknownValue([0xfc])), kInvalid);
274+
assert.throws(() => importBytes(withUnknownValue([0xff])), kInvalid);
275+
// Truncated values.
276+
assert.throws(() => importBytes(withUnknownValue([...head(3, 10), 0x61])),
277+
kInvalid);
278+
assert.throws(() => importBytes(withUnknownValue([0xfb, 0x00, 0x00])),
279+
kInvalid);
280+
assert.throws(() => importBytes(withUnknownValue(head(4, 5))), kInvalid);
281+
// Nesting is limited to 16 levels.
282+
const nested = (depth) => [...new Array(depth).fill(0x81), 0x00];
283+
assert.strictEqual(importBytes(withUnknownValue(nested(16))).count, 0);
284+
assert.throws(() => importBytes(withUnknownValue(nested(17))), kInvalid);
285+
}

0 commit comments

Comments
 (0)