From d68ba3db55b9df6ccb913dbd742f25e1496f1915 Mon Sep 17 00:00:00 2001 From: oziass Date: Wed, 14 Oct 2026 05:49:54 -0400 Subject: [PATCH] fix(webapp): convert epoch ms to BigInt before nanosecond multiplication Multiplying epoch milliseconds by 1e6 as a Number exceeds Number.MAX_SAFE_INTEGER, so the product is rounded to a multiple of 256 before it is converted to BigInt. Span start times and durations could therefore be off by up to a few hundred nanoseconds. Fixes #3292 --- .../app/v3/eventRepository/common.server.ts | 4 +- .../app/v3/eventRepository/index.server.ts | 2 +- .../webapp/app/v3/runEngineHandlers.server.ts | 2 +- .../webapp/test/eventRepositoryCommon.test.ts | 41 +++++++++++++++++++ 4 files changed, 45 insertions(+), 4 deletions(-) create mode 100644 apps/webapp/test/eventRepositoryCommon.test.ts diff --git a/apps/webapp/app/v3/eventRepository/common.server.ts b/apps/webapp/app/v3/eventRepository/common.server.ts index 346e2ab6419..4fd36c85749 100644 --- a/apps/webapp/app/v3/eventRepository/common.server.ts +++ b/apps/webapp/app/v3/eventRepository/common.server.ts @@ -26,7 +26,7 @@ export function extractContextFromCarrier(carrier: Record) { } export function getNowInNanoseconds(): bigint { - return BigInt(new Date().getTime() * 1_000_000); + return convertDateToNanoseconds(new Date()); } export function getDateFromNanoseconds(nanoseconds: bigint): Date { @@ -40,7 +40,7 @@ export function calculateDurationFromStart( ) { const $endtime = typeof endTime === "string" ? new Date(endTime) : endTime; - const duration = Number(BigInt($endtime.getTime() * 1_000_000) - startTime); + const duration = Number(convertDateToNanoseconds($endtime) - startTime); if (minimumDuration && duration < minimumDuration) { return minimumDuration; diff --git a/apps/webapp/app/v3/eventRepository/index.server.ts b/apps/webapp/app/v3/eventRepository/index.server.ts index c599f4e6b96..670ec60f014 100644 --- a/apps/webapp/app/v3/eventRepository/index.server.ts +++ b/apps/webapp/app/v3/eventRepository/index.server.ts @@ -208,7 +208,7 @@ async function recordRunEvent( runId: foundRun.friendlyId, ...attributes, }, - startTime: BigInt((startTime?.getTime() ?? Date.now()) * 1_000_000), + startTime: BigInt(startTime?.getTime() ?? Date.now()) * BigInt(1_000_000), ...optionsRest, }); diff --git a/apps/webapp/app/v3/runEngineHandlers.server.ts b/apps/webapp/app/v3/runEngineHandlers.server.ts index ee4ef2e278d..08e3194bfc7 100644 --- a/apps/webapp/app/v3/runEngineHandlers.server.ts +++ b/apps/webapp/app/v3/runEngineHandlers.server.ts @@ -557,7 +557,7 @@ export function registerRunEngineEventBusHandlers() { ); await eventRepository.recordEvent(retryMessage, { - startTime: BigInt(time.getTime() * 1000000), + startTime: BigInt(time.getTime()) * BigInt(1_000_000), taskSlug: run.taskIdentifier, environment, attributes: { diff --git a/apps/webapp/test/eventRepositoryCommon.test.ts b/apps/webapp/test/eventRepositoryCommon.test.ts new file mode 100644 index 00000000000..7d70aa41f00 --- /dev/null +++ b/apps/webapp/test/eventRepositoryCommon.test.ts @@ -0,0 +1,41 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + calculateDurationFromStart, + convertDateToNanoseconds, + getNowInNanoseconds, +} from "~/v3/eventRepository/common.server"; + +// 1700000000001 * 1e6 is not representable as a double (spacing is 256 at ~1.7e18), +// so Number multiplication before BigInt conversion rounds it to ...000999936. +const unsafeMs = 1_700_000_000_001; +const unsafeNs = 1_700_000_000_001_000_000n; + +describe("eventRepository nanosecond conversions", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it("convertDateToNanoseconds keeps exact precision beyond MAX_SAFE_INTEGER", () => { + expect(convertDateToNanoseconds(new Date(unsafeMs))).toBe(unsafeNs); + }); + + it("getNowInNanoseconds keeps exact precision beyond MAX_SAFE_INTEGER", () => { + vi.useFakeTimers(); + vi.setSystemTime(unsafeMs); + + expect(getNowInNanoseconds()).toBe(unsafeNs); + expect(getNowInNanoseconds() % 1_000_000n).toBe(0n); + }); + + it("calculateDurationFromStart is exact for sub-millisecond-sensitive end times", () => { + const startTime = convertDateToNanoseconds(new Date(unsafeMs - 1)); + + expect(calculateDurationFromStart(startTime, new Date(unsafeMs))).toBe(1_000_000); + }); + + it("calculateDurationFromStart applies the minimum duration", () => { + const startTime = convertDateToNanoseconds(new Date(unsafeMs - 1)); + + expect(calculateDurationFromStart(startTime, new Date(unsafeMs), 5_000_000)).toBe(5_000_000); + }); +});