diff --git a/package-lock.json b/package-lock.json index c302015..3a7c674 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ ], "devDependencies": { "@eslint/js": "^9.39.2", + "@opentelemetry/api": "^1.9.0", "@swc/core": "^1.3.55", "@swc/helpers": "^0.5.1", "@types/jest": "^29.5.1", @@ -226,6 +227,7 @@ "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", @@ -897,6 +899,7 @@ "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.3.tgz", "integrity": "sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@grpc/proto-loader": "^0.8.0", "@js-sdsl/ordered-map": "^4.4.2" @@ -1493,6 +1496,7 @@ "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", "dev": true, "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=8.0.0" } @@ -1669,6 +1673,7 @@ "dev": true, "hasInstallScript": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@swc/counter": "^0.1.3", "@swc/types": "^0.1.25" @@ -1884,6 +1889,7 @@ "integrity": "sha512-TXTnIcNJQEKwThMMqBXsZ4VGAza6bvN4pa41Rkqoio6QBKMvo+5lexeTMScGCIxtzgQJzElcvIltani+adC5PQ==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "tslib": "^2.8.0" } @@ -2045,6 +2051,7 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.9.tgz", "integrity": "sha512-PD03/U8g1F9T9MI+1OBisaIARhSzeidsUjQaf51fOxrfjeiKN9bLVO06lHuHYjxdnqLWJijJHfqXPSJri2EM2A==", "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -2118,6 +2125,7 @@ "integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.54.0", "@typescript-eslint/types": "8.54.0", @@ -2359,6 +2367,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2683,6 +2692,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -3330,6 +3340,7 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -4245,6 +4256,7 @@ "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -5991,6 +6003,7 @@ "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -6715,6 +6728,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -6866,6 +6880,7 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -6952,6 +6967,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/package.json b/package.json index b2f8d66..11dd682 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "homepage": "https://github.com/microsoft/durabletask-js#readme", "devDependencies": { "@eslint/js": "^9.39.2", + "@opentelemetry/api": "^1.9.0", "@swc/core": "^1.3.55", "@swc/helpers": "^0.5.1", "@types/jest": "^29.5.1", @@ -62,4 +63,4 @@ "lint-staged": { "*.{js,jsx,ts,tsx}": "eslint" } -} \ No newline at end of file +} diff --git a/packages/durabletask-js/src/worker/runtime-orchestration-context.ts b/packages/durabletask-js/src/worker/runtime-orchestration-context.ts index 3a71007..a14f5e2 100644 --- a/packages/durabletask-js/src/worker/runtime-orchestration-context.ts +++ b/packages/durabletask-js/src/worker/runtime-orchestration-context.ts @@ -297,7 +297,7 @@ export class RuntimeOrchestrationContext extends OrchestrationContext { // If a number is passed, we use it as the number of seconds to wait // we use instanceof Date as number is not a native Javascript type if (!(fireAt instanceof Date)) { - fireAt = new Date(Date.now() + fireAt * 1000); + fireAt = new Date(this._currentUtcDatetime.getTime() + fireAt * 1000); } const action = ph.newCreateTimerAction(id, fireAt); diff --git a/test/e2e-azuremanaged/orchestration.spec.ts b/test/e2e-azuremanaged/orchestration.spec.ts index 16f9ee9..7c50aec 100644 --- a/test/e2e-azuremanaged/orchestration.spec.ts +++ b/test/e2e-azuremanaged/orchestration.spec.ts @@ -385,6 +385,68 @@ describe("Durable Task Scheduler (DTS) E2E Tests", () => { expect(expectedCompletionSecond).toBeLessThanOrEqual(actualCompletionSecond); }, 31000); + it("should use deterministic time for createTimer(seconds) across replays", async () => { + // Issue #134: createTimer(seconds) used Date.now() instead of ctx.currentUtcDateTime, + // violating the determinism contract. During replay, Date.now() returns a different + // wall-clock time, producing a different timer fire-at value. The fix uses the + // orchestration's deterministic time (ctx.currentUtcDateTime) instead. + // + // This test validates the fix by: + // 1. Using createTimer(seconds) with an activity before and after it + // 2. The activity after the timer forces a replay of the timer yield + // 3. If the timer fire-at time were non-deterministic, the replay would either + // throw a NonDeterminismError or produce incorrect behavior + const sayHello = async (_: ActivityContext, name: string) => `Hello, ${name}!`; + + const timerDeterminismOrchestrator: TOrchestrator = async function* (ctx: OrchestrationContext): any { + // Record orchestration time BEFORE the timer + const timeBefore = ctx.currentUtcDateTime.toISOString(); + + // Call an activity to force a replay after the timer + const greeting1: string = yield ctx.callActivity(sayHello, "before-timer"); + + // Use createTimer with a relative seconds value (the code path being tested) + const timerDelay = 2; + yield ctx.createTimer(timerDelay); + + // Record orchestration time AFTER the timer + const timeAfter = ctx.currentUtcDateTime.toISOString(); + + // Call another activity — this forces another replay that re-executes + // the createTimer(seconds) path with the replayed history + const greeting2: string = yield ctx.callActivity(sayHello, "after-timer"); + + return { + timeBefore, + timeAfter, + greeting1, + greeting2, + timerDelay, + }; + }; + + taskHubWorker.addOrchestrator(timerDeterminismOrchestrator); + taskHubWorker.addActivity(sayHello); + await taskHubWorker.start(); + + const id = await taskHubClient.scheduleNewOrchestration(timerDeterminismOrchestrator); + const state = await taskHubClient.waitForOrchestrationCompletion(id, undefined, 30); + + expect(state).toBeDefined(); + expect(state?.runtimeStatus).toEqual(OrchestrationStatus.ORCHESTRATION_STATUS_COMPLETED); + expect(state?.failureDetails).toBeUndefined(); + + const output = JSON.parse(state?.serializedOutput ?? "{}"); + expect(output.greeting1).toEqual("Hello, before-timer!"); + expect(output.greeting2).toEqual("Hello, after-timer!"); + expect(output.timerDelay).toEqual(2); + + // Verify time progressed (timeAfter should be at least timerDelay seconds after timeBefore) + const before = new Date(output.timeBefore).getTime(); + const after = new Date(output.timeAfter).getTime(); + expect(after).toBeGreaterThanOrEqual(before + 2000); + }, 45000); + it("should be able to terminate an orchestration", async () => { const orchestrator: TOrchestrator = async function* (ctx: OrchestrationContext, _: any): any { const res = yield ctx.waitForExternalEvent("my_event");