Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions doc/api/crypto.md
Original file line number Diff line number Diff line change
Expand Up @@ -5838,6 +5838,10 @@ added: REPLACEME
random data to generate up to 128 random UUIDs. To generate a UUID
without using the cache, set `disableEntropyCache` to `true`.
**Default:** `false`.
* `monotonic` {boolean} When `true`, guarantees that UUIDs generated
within the same millisecond are strictly increasing by using a counter
in the `rand_a` field, as described in [RFC 9562][] Section 6.2.
**Default:** `false`.
* Returns: {string}

Generates a random [RFC 9562][] version 7 UUID. The UUID contains a millisecond
Expand Down
73 changes: 57 additions & 16 deletions lib/internal/crypto/random.js
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,9 @@ let uuidData;
let uuidNotBuffered;
let uuidBatch = 0;

let v7LastTimestamp = -1;
let v7Counter = 0;

let hexBytesCache;
function getHexBytes() {
if (hexBytesCache === undefined) {
Expand Down Expand Up @@ -415,48 +418,86 @@ function randomUUID(options) {
return disableEntropyCache ? getUnbufferedUUID() : getBufferedUUID();
}

function writeTimestamp(buf, offset) {
function advanceV7(seed) {
const now = DateNow();
const msb = now / (2 ** 32);
buf[offset] = msb >>> 8;
buf[offset + 1] = msb;
buf[offset + 2] = now >>> 24;
buf[offset + 3] = now >>> 16;
buf[offset + 4] = now >>> 8;
buf[offset + 5] = now;
if (now > v7LastTimestamp) {
v7LastTimestamp = now;
v7Counter = seed & 0xFFF;
} else {
v7Counter++;
if (v7Counter > 0xFFF) {
v7LastTimestamp++;
v7Counter = 0;
}
Comment on lines +428 to +431
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Prematurely incrementing the timestamp is a permissible way to handle counter overflow in the RFC, but I think we need to be really clear in the documentation that the potential timestamp drift in the generated UUIDs is effectively unbounded, and could diverge ad infinitum if generating UUIDs at very high frequency.

}
Comment on lines +423 to +432
Copy link
Copy Markdown
Member

@Renegade334 Renegade334 Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is problematic if the clock moves backwards significantly, as the timestamp ends up frozen until such a time as the clock catches up. I don't know how other implementations handle this possibility.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From postgresql: https://raw.githubusercontent.com/postgres/postgres/dbf217c1c7c2744a18db489c255255e07cfbb110/src/backend/utils/adt/uuid.c

If the wall clock returns a value that isn't at least SUBMS_MINIMAL_STEP_NS ahead of the last call, the returned timestamp is synthetically bumped forward

Copy link
Copy Markdown
Member Author

@araujogui araujogui Apr 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

}

function makeUUIDv7(buf, offset, monotonic = false) {
let timestamp, randA;
if (monotonic) {
const seed = ((buf[offset + 6] & 0x0f) << 8) | buf[offset + 7];
advanceV7(seed);
timestamp = v7LastTimestamp;
randA = v7Counter;
} else {
timestamp = DateNow();
randA = ((buf[offset + 6] & 0x0f) << 8) | buf[offset + 7];
}
const kHexBytes = getHexBytes();
const msb = timestamp / (2 ** 32);
return kHexBytes[msb >>> 8] +
kHexBytes[msb & 0xff] +
kHexBytes[timestamp >>> 24] +
kHexBytes[(timestamp >>> 16) & 0xff] +
'-' +
kHexBytes[(timestamp >>> 8) & 0xff] +
kHexBytes[timestamp & 0xff] +
'-' +
kHexBytes[(randA >>> 8) | 0x70] +
kHexBytes[randA & 0xff] +
'-' +
kHexBytes[(buf[offset + 8] & 0x3f) | 0x80] +
kHexBytes[buf[offset + 9]] +
'-' +
kHexBytes[buf[offset + 10]] +
kHexBytes[buf[offset + 11]] +
kHexBytes[buf[offset + 12]] +
kHexBytes[buf[offset + 13]] +
kHexBytes[buf[offset + 14]] +
kHexBytes[buf[offset + 15]];
}

function getBufferedUUIDv7() {
function getBufferedUUIDv7(monotonic = false) {
uuidData ??= secureBuffer(16 * kBatchSize);
if (uuidData === undefined)
throw new ERR_OPERATION_FAILED('Out of memory');

if (uuidBatch === 0) randomFillSync(uuidData);
uuidBatch = (uuidBatch + 1) % kBatchSize;
const offset = uuidBatch * 16;
writeTimestamp(uuidData, offset);
return serializeUUID(uuidData, 0x70, 0x80, offset);
return makeUUIDv7(uuidData, uuidBatch * 16, monotonic);
}

function getUnbufferedUUIDv7() {
function getUnbufferedUUIDv7(monotonic = false) {
uuidNotBuffered ??= secureBuffer(16);
if (uuidNotBuffered === undefined)
throw new ERR_OPERATION_FAILED('Out of memory');
randomFillSync(uuidNotBuffered, 6);
writeTimestamp(uuidNotBuffered, 0);
return serializeUUID(uuidNotBuffered, 0x70, 0x80);
return makeUUIDv7(uuidNotBuffered, 0, monotonic);
}

function randomUUIDv7(options) {
if (options !== undefined)
validateObject(options, 'options');
const {
disableEntropyCache = false,
monotonic = false,
} = options || kEmptyObject;

validateBoolean(disableEntropyCache, 'options.disableEntropyCache');
validateBoolean(monotonic, 'options.monotonic');

return disableEntropyCache ? getUnbufferedUUIDv7() : getBufferedUUIDv7();
return disableEntropyCache ?
getUnbufferedUUIDv7(monotonic) : getBufferedUUIDv7(monotonic);
}

function createRandomPrimeJob(type, size, options) {
Expand Down
40 changes: 31 additions & 9 deletions test/parallel/test-crypto-randomuuidv7.js
Original file line number Diff line number Diff line change
Expand Up @@ -60,20 +60,33 @@ const {
}

{
let prev = randomUUIDv7();
const opts = { monotonic: true };
let prev = randomUUIDv7(opts);
for (let i = 0; i < 100; i++) {
const curr = randomUUIDv7();
// UUIDs with later timestamps must sort after earlier ones.
// Within the same millisecond, ordering depends on random bits,
// so we only assert >= on the timestamp portion.
const prevTs = parseInt(prev.replace(/-/g, '').slice(0, 12), 16);
const currTs = parseInt(curr.replace(/-/g, '').slice(0, 12), 16);
assert(currTs >= prevTs,
`Timestamp went backwards: ${currTs} < ${prevTs}`);
const curr = randomUUIDv7(opts);
// With a monotonic counter in rand_a, each UUID must be strictly greater
// than the previous regardless of whether the timestamp changed.
assert(curr > prev,
`UUID ordering violated: ${curr} <= ${prev}`);
prev = curr;
}
}

// Sub-millisecond ordering: a tight synchronous burst exercises the counter
// increment path and must also produce strictly increasing UUIDs.
{
const opts = { monotonic: true };
const burst = [];
for (let i = 0; i < 500; i++) {
burst.push(randomUUIDv7(opts));
}
for (let i = 1; i < burst.length; i++) {
assert(burst[i] > burst[i - 1],
`Sub-millisecond ordering violated at index ${i}: ` +
`${burst[i]} <= ${burst[i - 1]}`);
}
}

// Ensure randomUUIDv7 takes no arguments (or ignores them gracefully).
{
const uuid = randomUUIDv7();
Expand All @@ -92,13 +105,22 @@ const {
assert.match(randomUUIDv7({ disableEntropyCache: true }), uuidv7Regex);
assert.match(randomUUIDv7({ disableEntropyCache: true }), uuidv7Regex);

// monotonic: false — rand_a is random; UUIDs must still be valid but are not
// guaranteed to be strictly ordered within the same millisecond.
assert.match(randomUUIDv7({ monotonic: false }), uuidv7Regex);
assert.match(randomUUIDv7({ monotonic: false, disableEntropyCache: true }), uuidv7Regex);

assert.throws(() => randomUUIDv7(1), {
code: 'ERR_INVALID_ARG_TYPE',
});

assert.throws(() => randomUUIDv7({ disableEntropyCache: '' }), {
code: 'ERR_INVALID_ARG_TYPE',
});

assert.throws(() => randomUUIDv7({ monotonic: 1 }), {
code: 'ERR_INVALID_ARG_TYPE',
});
}

{
Expand Down
Loading