fix(v1): carry nsecs overflow into the timestamp's high bits - #972
Conversation
The v1/v6 timestamp is `msecs * 10000 + nsecs` in 100-nanosecond intervals since the Gregorian epoch. It needs 57+ bits, so `v1Bytes()` computes it as a 32-bit `time_low` and a 28-bit `time_mid`/`time_high` half. `nsecs` was added to the low half only, and the low half was reduced mod 2^32, so a `nsecs` value that pushed it past 2^32 wrapped without incrementing the high half. The high half was also derived from `msecs` alone via a floating-point divide, which cannot see that carry at all. The result is a timestamp 2^32 * 100ns = 429.4967296 seconds in the past. It happens whenever `msecs * 10000` lands within 10000 of a 2^32 boundary, i.e. for one millisecond about every 430 seconds, and then only for the `nsecs` values above the boundary. `v1()`'s internal state walks `nsecs` from 0 to 9999 within a millisecond, so it is reachable with no options at all: generating enough ids inside that millisecond makes the timestamp jump backwards, breaking the monotonicity that v1 ordering and v6 sorting depend on. Because the wrap maps two distinct instants onto one encoded timestamp, a generator with a stable `clockseq` and `node` can also emit the same id twice, 429.496s apart. Compute the low half's own carry and fold it into the high half. `msecs` is split at bit 28 rather than 32 because `0x10000000 * 10000` is exactly `625 * 0x100000000`, so the split is exact and the high half needs no floating-point division.
|
Great catch! Fwiw, this issue has been around since the original version of I suspect the reason this has gone undetected is that it requires extraordinary levels of Regardless, this is still a good catch, as I said, so still worth merging. let overflowCount = 0;
for (let msecs = 0; msecs < 1_000_000_000; msecs++) {
const tl = (msecs & 0xfffffff) * 10000;
const proximity = 0x100000000 - (tl % 0x100000000);
if (proximity < 10000) {
overflowCount++;
if (proximity < 100) {
console.log({ msecs, proximity, overflowCount });
}
}
}
console.log({ overflowCount }); |

v1()andv6()can encode a timestamp 429.4967296 seconds (2^32 × 100ns) in the past, which breaks the monotonicity that v1 ordering and v6 sorting depend on, and can make a stable generator emit the same id twice.No options are needed to reach it.
Root cause
The RFC 9562 v1 timestamp is
msecs * 10000 + nsecs, in 100-nanosecond intervals since the Gregorian epoch. That needs 57+ bits, sov1Bytes()builds it as a 32-bittime_lowand a 28-bittime_mid/time_highhalf:nsecsis added to the low half, and the low half is then reduced% 0x100000000— so whennsecspushes it past 2^32 it wraps, and nothing increments the high half. The high half is derived frommsecsalone, so it cannot see that carry.The wrap happens when
msecs * 10000lands within 10000 of a 2^32 boundary — one millisecond roughly every 430 seconds — and then only for thensecsvalues at or above the boundary. Over a 24-hour span, 201 milliseconds are affected.Reproducing
v1()'s internal state walksnsecsfrom 0 to 9999 within a millisecond, so the default no-options path hits this by simply generating enough ids inside the unlucky millisecond:time_lowrolls over fromffffffffto00000000andtime_midnever advances, so the timestamp goes backwards by 429.4967296 s mid-millisecond.The same thing via explicit options, checked against the exact RFC value:
Because the wrap maps two distinct instants onto one encoded timestamp, a generator with a stable
clockseqandnodecan also produce a byte-identical id twice:Two ids 429.496 s apart, identical. I did not report this privately because neither input is externally reachable —
msecscomes from the system clock andnsecsfrom the generator's own call counter — and v1 ids are not secrets by design. Happy to move it if you'd rather.The fix
Compute the low half's own carry and fold it into the high half:
msecsis split at bit 28 rather than 32 because0x10000000 * 10000is exactly625 * 0x100000000. Somsecs' high bits contribute only to the high half (as× 625) and its low bits only to the low half — the split is exact, and the high half no longer needs a floating-point divide to land on an integer.Every intermediate stays well inside 2^53, and both divisions are by powers of two, so they're exact.
Verification
nsecsvalues exhaustively on every carry-boundary millisecond found. Current code: 52,736 mismatches. Patched: 0.mainand pass with the fix:v1 timestamp carries nsecs into time_mid— decodes the 60-bit timestamp back out and compares it to the exact RFC value on both sides of a carry boundary.v1 sort order (time_low overflow)— ordering across the boundary, reusing the existingcompareV1TimeFieldhelper.sort by creation time (time_low overflow)— the v6 lexical-sort equivalent.npm test80/80,npm run test:node, andnpm run lintall pass.c232ab00-9414-11ec-b3c8-9f68deced846) is unchanged, as are all existing timestamp assertions.Math.floor/%and it cost ~2× on the arithmetic, so this version uses the bitwise equivalents instead. At 3×10^7 iterations the changed math runs at 9.3–10.3M ops/s versus 5.9–9.9M for the current code — no measurable regression, anduuid.v1()intest:benchmarkstays within its own ±5% run-to-run noise (~193k ops/s).I left
CHANGELOG.mdand the version alone for release-please. No dependency, export, or API changes.