Commit 5af276ec309 for nodejs
commit 5af276ec30903c387022fa52cfe872c253bac9ee
Author: James M Snell <jasnell@gmail.com>
Date: Fri Sep 18 00:16:09 2026 +0000
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: https://github.com/nodejs/node/pull/66098
Reviewed-By: Xuguang Mei <meixuguang@gmail.com>
diff --git a/doc/api/perf_hooks.md b/doc/api/perf_hooks.md
index 8fdf5b76989..fa7b9f7ba5a 100644
--- a/doc/api/perf_hooks.md
+++ b/doc/api/perf_hooks.md
@@ -1816,6 +1816,11 @@ console.log(snapshot.percentile(99));
<!-- YAML
added: v26.9.0
+changes:
+ - version: REPLACEME
+ pr-url: https://github.com/nodejs/node/pull/66098
+ description: Format version 2 is supported. Unknown keys in version 2
+ data are ignored.
-->
* `data` {Uint8Array} A CBOR-encoded histogram previously produced by
@@ -1826,6 +1831,9 @@ Reconstructs a histogram from a CBOR-encoded `Uint8Array`. The returned
histogram is a full {RecordableHistogram} with all bucket data, configuration,
and EWMA state restored. New values can be recorded into it.
+Data in any format version produced by [`histogram.export()`][] can be
+imported. See [histogram export format compatibility][] for details.
+
```js
const { createHistogram, importHistogram } = require('node:perf_hooks');
@@ -2219,6 +2227,10 @@ loop delay threshold.
<!-- YAML
added: v26.9.0
+changes:
+ - version: REPLACEME
+ pr-url: https://github.com/nodejs/node/pull/66098
+ description: The output uses format version 2.
-->
* Returns: {Uint8Array}
@@ -2237,7 +2249,7 @@ The CBOR payload is a map with integer keys:
| Key | Type | Field |
| --- | ------- | --------------------------------------------- |
-| 0 | uint | Format version (currently 1) |
+| 0 | uint | Format version (currently 2) |
| 1 | uint | Lowest discernible value |
| 2 | uint | Highest trackable value |
| 3 | uint | Significant figures |
@@ -2252,6 +2264,23 @@ The CBOR payload is a map with integer keys:
Any standard CBOR decoder can parse the output.
+#### Histogram export format compatibility
+
+[`perf_hooks.importHistogram()`][] accepts every format version that
+`histogram.export()` has produced:
+
+* Version 1 was produced by Node.js v26.9.0. Data with a version 1 key, or
+ without a version key, is imported with the original semantics: keys
+ that are not listed above are rejected.
+* Version 2 has the same layout as version 1. Keys that are not recognized
+ are ignored, so later versions of Node.js can add fields to version 2
+ data without changing the version, and the data remains importable.
+
+Data with any other version is rejected.
+
+When the total count, min, or max value is absent, it is derived from the
+bucket counts. A total count that is present must match the bucket counts.
+
### `histogram.ewmaMean`
<!-- YAML
@@ -3308,3 +3337,4 @@ dns.promises.resolve('localhost');
[`timeOrigin`]: https://w3c.github.io/hr-time/#dom-performance-timeorigin
[`window.performance.toJSON`]: https://developer.mozilla.org/en-US/docs/Web/API/Performance/toJSON
[`window.performance`]: https://developer.mozilla.org/en-US/docs/Web/API/Window/performance
+[histogram export format compatibility]: #histogram-export-format-compatibility
diff --git a/src/histogram.cc b/src/histogram.cc
index 474b60b9ea7..1b9560bea95 100644
--- a/src/histogram.cc
+++ b/src/histogram.cc
@@ -1109,8 +1109,66 @@ static bool CborReadNumber(const uint8_t*& p, const uint8_t* end, double* val) {
return true;
}
-// Histogram export format version.
-constexpr uint64_t kExportVersion = 1;
+// Maximum nesting depth of the values that CborSkipItem() skips over.
+constexpr int kCborMaxSkipDepth = 16;
+
+// Skip over one well-formed data item, including any items nested within it.
+// Indefinite-length items are not supported.
+static bool CborSkipItem(const uint8_t*& p, const uint8_t* end, int depth = 0) {
+ if (p >= end || depth > kCborMaxSkipDepth) return false;
+ const uint8_t major = *p >> 5;
+ const uint8_t info = *p & 0x1f;
+
+ if (major == 7) {
+ // Simple values and floats. Additional information 24 to 27 is followed
+ // by 1, 2, 4, or 8 bytes; 28 to 30 are reserved, and 31 is a break.
+ size_t extra;
+ if (info <= 23) {
+ extra = 0;
+ } else if (info <= 27) {
+ extra = size_t{1} << (info - 24);
+ } else {
+ return false;
+ }
+ p++;
+ if (static_cast<size_t>(end - p) < extra) return false;
+ p += extra;
+ return true;
+ }
+
+ uint64_t arg;
+ if (!CborReadUint(p, end, &arg)) return false;
+ switch (major) {
+ case 0: // Unsigned integer.
+ case 1: // Negative integer.
+ return true;
+ case 2: // Byte string.
+ case 3: // Text string.
+ if (arg > static_cast<uint64_t>(end - p)) return false;
+ p += arg;
+ return true;
+ case 4: // Array.
+ case 5: { // Map.
+ // Each item takes at least one byte, which also bounds the loop.
+ if (arg > static_cast<uint64_t>(end - p)) return false;
+ const uint64_t items = major == 4 ? arg : arg * 2;
+ for (uint64_t i = 0; i < items; i++) {
+ if (!CborSkipItem(p, end, depth + 1)) return false;
+ }
+ return true;
+ }
+ case 6: // Tag.
+ return CborSkipItem(p, end, depth + 1);
+ }
+ return false;
+}
+
+// Histogram export format version. Version 2 has the same layout as
+// version 1, but importers ignore unknown keys in version 2 data, so fields
+// can be added without changing the version. Version 1 data is imported
+// with its original semantics, which reject unknown keys.
+constexpr uint64_t kExportVersion = 2;
+constexpr uint64_t kStrictExportVersion = 1;
// Integer keys for the top-level CBOR map.
constexpr uint64_t kKeyVersion = 0;
@@ -1423,7 +1481,7 @@ Histogram::PercentileCIResult Histogram::PercentileCI(double percentile,
// common case.
//
// Layout: a CBOR map with integer keys:
-// 0 -> uint format version (currently 1)
+// 0 -> uint format version (currently 2)
// 1 -> uint lowest discernible value
// 2 -> uint highest trackable value
// 3 -> uint significant figures
@@ -1441,6 +1499,13 @@ Histogram::PercentileCIResult Histogram::PercentileCI(double percentile,
// 2 -> float64 variance
// 3 -> float64 error rate
// 4 -> uint threshold
+//
+// Compatibility: Import() accepts format versions 1 and 2, whose layouts are
+// identical. In version 2 data, keys that the importer does not recognize are
+// skipped, so new fields can be added without changing the version. Version 1
+// data keeps its original semantics, in which unknown keys are rejected. Any
+// field may be absent; the total count, min, and max are then derived from
+// the counts.
std::vector<uint8_t> Histogram::Export() const {
RwLock::ScopedReadLock lock(mutex_);
@@ -1547,12 +1612,16 @@ std::shared_ptr<Histogram> Histogram::Import(const uint8_t* data, size_t len) {
int32_t norm_offset = 0;
double conv_ratio = 1.0;
int32_t counts_len = 0;
- uint64_t version = 0;
+ // Data without a version key is imported as version 1.
+ uint64_t version = kStrictExportVersion;
// Bitsets of the keys read so far, used to reject duplicate keys and to
// tell whether a field was present. All known keys are less than 64.
uint64_t seen_keys = 0;
uint64_t seen_ewma_keys = 0;
+ // Unknown keys are skipped while parsing, because the version key that
+ // determines whether they are allowed can appear anywhere in the map.
+ bool has_unknown_keys = false;
auto mark_seen = [](uint64_t* seen, uint64_t key) {
const uint64_t bit = uint64_t{1} << key;
if (*seen & bit) return false;
@@ -1577,13 +1646,20 @@ std::shared_ptr<Histogram> Histogram::Import(const uint8_t* data, size_t len) {
// Read key (unsigned int).
uint64_t key;
if (!CborReadArgument(p, end, kCborUint, &key)) return nullptr;
- if (key > kKeyEwma) return nullptr; // Unknown key.
+ if (key > kKeyEwma) {
+ // Unknown key.
+ if (!CborSkipItem(p, end)) return nullptr;
+ has_unknown_keys = true;
+ continue;
+ }
if (!mark_seen(&seen_keys, key)) return nullptr; // Duplicate key.
switch (key) {
case kKeyVersion:
if (!CborReadArgument(p, end, kCborUint, &version)) return nullptr;
- if (version != kExportVersion) return nullptr;
+ if (version < kStrictExportVersion || version > kExportVersion) {
+ return nullptr;
+ }
break;
case kKeyLowest:
if (!CborReadInt64(p, end, &lowest)) return nullptr;
@@ -1648,7 +1724,12 @@ std::shared_ptr<Histogram> Histogram::Import(const uint8_t* data, size_t len) {
for (uint64_t j = 0; j < sub_size; j++) {
uint64_t sub_key;
if (!CborReadArgument(p, end, kCborUint, &sub_key)) return nullptr;
- if (sub_key > kEwmaThreshold) return nullptr; // Unknown EWMA key.
+ if (sub_key > kEwmaThreshold) {
+ // Unknown EWMA key.
+ if (!CborSkipItem(p, end)) return nullptr;
+ has_unknown_keys = true;
+ continue;
+ }
if (!mark_seen(&seen_ewma_keys, sub_key)) return nullptr;
switch (sub_key) {
case kEwmaAlpha:
@@ -1673,6 +1754,9 @@ std::shared_ptr<Histogram> Histogram::Import(const uint8_t* data, size_t len) {
}
}
+ // Version 1 data keeps its original semantics: unknown keys are rejected.
+ if (version == kStrictExportVersion && has_unknown_keys) return nullptr;
+
// Reconstruct the histogram.
Options opts;
opts.lowest = lowest;
diff --git a/test/parallel/test-perf-hooks-histogram-import.js b/test/parallel/test-perf-hooks-histogram-import.js
index e4079f2d361..c5d7f75c692 100644
--- a/test/parallel/test-perf-hooks-histogram-import.js
+++ b/test/parallel/test-perf-hooks-histogram-import.js
@@ -139,3 +139,147 @@ assert.throws(() => importBytes(map([
assert.throws(() => importBytes(map([...kLayout, counts(5, 2, 0, 1)])),
kInvalid);
}
+
+// --- Format versions ---
+
+function assertSameHistogram(actual, expected) {
+ assert.strictEqual(actual.count, expected.count);
+ assert.strictEqual(actual.min, expected.min);
+ assert.strictEqual(actual.max, expected.max);
+ assert.strictEqual(actual.percentile(50), expected.percentile(50));
+ assert.strictEqual(actual.percentile(100), expected.percentile(100));
+}
+
+{
+ // Version 1 data, as produced by Node.js v26.9.0, remains importable.
+ const v1 = Buffer.from(
+ 'ac00010101021b001fffffffffffff030304070501061a075bcd15070008fb3f' +
+ 'f00000000000000919b0000a8c010101010101190bfd02191fa101191bba010b' +
+ 'a500fb3fc45d819a94b14c01fb4172dc620791dc2002fb431ce79e5650942003' +
+ 'fb3fd2bec33301886804191388', 'hex');
+ const h = importHistogram(new Uint8Array(v1));
+ assert.strictEqual(h.count, 7);
+ assert.strictEqual(h.min, 1);
+ assert.strictEqual(h.max, 123469823);
+ assert.strictEqual(h.mean, 17777921.42857143);
+ assert.strictEqual(h.stddev, 43136537.01467338);
+ assert.strictEqual(h.percentile(50), 4099);
+ assert.strictEqual(h.percentile(100), 123469823);
+ assert.strictEqual(h.ewmaMean, 19777056.47311032);
+ assert.strictEqual(h.ewmaStddev, 45099796.52633914);
+ assert.strictEqual(h.ewmaErrorRate, 0.29289321881345254);
+}
+
+{
+ // export() produces version 2 data, which round-trips.
+ const h = createHistogram({ halfLife: 4, threshold: 5000 });
+ for (const value of [1, 2, 3, 4096, 4097, 1000000, 123456789]) {
+ h.record(value);
+ }
+ const data = h.export();
+ // The first map entry is the version.
+ assert.deepStrictEqual([...data.subarray(1, 3)], [...uint(0), ...uint(2)]);
+ const h2 = importHistogram(data);
+ assertSameHistogram(h2, h);
+ assert.strictEqual(h2.ewmaMean, h.ewmaMean);
+ assert.strictEqual(h2.ewmaErrorRate, h.ewmaErrorRate);
+}
+
+{
+ // Unknown keys in version 2 data are ignored, whatever their values are.
+ const unknownValues = [
+ uint(2n ** 40n),
+ negint(7),
+ [...head(2, 3), 1, 2, 3], // Byte string.
+ text('hello'),
+ array([uint(1), array([text('nested')])]),
+ map([[text('a'), uint(1)], [uint(99), map([[uint(1), f64(2.5)]])]]),
+ [...head(6, 1), ...text('2026-09-17')], // Tag.
+ f64(1.5),
+ [0xf9, 0x3e, 0x00], // Float16.
+ [0xfa, 0x3f, 0xc0, 0x00, 0x00], // Float32.
+ [0xf5], // true
+ [0xf6], // null
+ [0xf8, 0xff], // Simple value 255.
+ ];
+ const expected = importBytes(
+ map([[uint(0), uint(2)], ...kLayout, counts(5, 2, 3, 1)]));
+ const h = importBytes(map([
+ [uint(0), uint(2)],
+ ...kLayout,
+ ...unknownValues.map((value, n) => [uint(12 + n), value]),
+ counts(5, 2, 3, 1),
+ ]));
+ assertSameHistogram(h, expected);
+
+ // Unknown keys may appear before the version key.
+ assertSameHistogram(importBytes(map([
+ [uint(12), text('before the version')],
+ [uint(0), uint(2)],
+ ...kLayout,
+ counts(5, 2, 3, 1),
+ ])), expected);
+
+ // Unknown keys in the EWMA state are ignored as well.
+ const withEwma = importBytes(map([
+ [uint(0), uint(2)],
+ ...kLayout,
+ counts(5, 2, 3, 1),
+ [uint(11), map([
+ [uint(0), f64(0.5)],
+ [uint(99), text('unknown')],
+ [uint(1), f64(6)],
+ ])],
+ ]));
+ assertSameHistogram(withEwma, expected);
+ assert.strictEqual(withEwma.ewmaMean, 6);
+}
+
+{
+ // Version 1 data, or data without a version, keeps the original semantics:
+ // unknown keys are rejected.
+ const unknown = [uint(12), uint(0)];
+ assert.throws(() => importBytes(map([[uint(0), uint(1)], ...kLayout, unknown])),
+ kInvalid);
+ assert.throws(() => importBytes(map([unknown, [uint(0), uint(1)], ...kLayout])),
+ kInvalid);
+ assert.throws(() => importBytes(map([...kLayout, unknown])), kInvalid);
+ assert.throws(() => importBytes(map([
+ [uint(0), uint(1)],
+ ...kLayout,
+ [uint(11), map([[uint(99), uint(0)]])],
+ ])), kInvalid);
+}
+
+// Other versions are rejected.
+for (const version of [0, 3, 99]) {
+ assert.throws(
+ () => importBytes(map([[uint(0), uint(version)], ...kLayout])),
+ kInvalid);
+}
+
+{
+ // Values of unknown keys must still be well-formed.
+ const withUnknownValue =
+ (value) => map([[uint(0), uint(2)], ...kLayout, [uint(12), value]]);
+
+ // Indefinite-length items are not supported.
+ assert.throws(() => importBytes(withUnknownValue([0x5f, 0x41, 0x00, 0xff])),
+ kInvalid);
+ assert.throws(() => importBytes(withUnknownValue([0x9f, 0x00, 0xff])),
+ kInvalid);
+ // Reserved additional information and break codes.
+ assert.throws(() => importBytes(withUnknownValue([0x1c])), kInvalid);
+ assert.throws(() => importBytes(withUnknownValue([0xfc])), kInvalid);
+ assert.throws(() => importBytes(withUnknownValue([0xff])), kInvalid);
+ // Truncated values.
+ assert.throws(() => importBytes(withUnknownValue([...head(3, 10), 0x61])),
+ kInvalid);
+ assert.throws(() => importBytes(withUnknownValue([0xfb, 0x00, 0x00])),
+ kInvalid);
+ assert.throws(() => importBytes(withUnknownValue(head(4, 5))), kInvalid);
+ // Nesting is limited to 16 levels.
+ const nested = (depth) => [...new Array(depth).fill(0x81), 0x00];
+ assert.strictEqual(importBytes(withUnknownValue(nested(16))).count, 0);
+ assert.throws(() => importBytes(withUnknownValue(nested(17))), kInvalid);
+}