Skip to content
Merged
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
3 changes: 2 additions & 1 deletion .github/workflows/pr-validation.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,8 @@ jobs:
# Install and run the durabletask-go sidecar for running e2e tests
- name: ✅ Run E2E tests with durabletask-go sidecar
run: |
go install github.com/microsoft/durabletask-go@main
# Last sidecar revision; upstream main removed the executable in microsoft/durabletask-go#158.
go install github.com/microsoft/durabletask-go@3fe35d93fe1d2bdab21a3d85c14867532adef0b0
durabletask-go --port 4001 &
sleep 5 # Wait for sidecar to be ready
npm run test:e2e:internal
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

### New

- Align core and test worker timers with Python: three-day segments by default and
`maximumTimerIntervalMs` override (`null`, zero, or negative disables segmentation).
Positive fractions round up to milliseconds to match JavaScript Date precision.
- Add .NET-aligned worker history streaming: hydrate service-selected history before
version checks and replay. History errors produce a Failed completion; shutdown
cancels without submitting completion.
Expand All @@ -19,6 +22,8 @@

### Fixes

- Reject uninitialized `whenAll` results after a canceled child's completion callback throws,
rather than allowing a caught cancellation followed by `yield` to report success.
- Align worker response cancellation with .NET: `stop()` cancels initial sends as well
as retries and backoff for all work items. Work finishing after stop no longer sends a response.
- Retry worker completion and version-rejection responses on transient gRPC failures, reusing
Expand All @@ -33,6 +38,16 @@
disposal of replaced channels. Sidecars that do not send health-ping work items, including the
current durabletask-go sidecar, should set `silentDisconnectTimeoutMs` to `0`.

### Breaking changes

- Core timers now default to three-day segments instead of native timers; Azure-managed workers
explicitly retain native timers.
- `TimerTask.cancel()` now returns a boolean, marks the timer canceled and complete, and notifies
composite parents. Timer `result` aliases `getResult()` and throws while pending, failed, or
canceled (`TaskCancelledError`). This matches Python's timer cancellation contract, including
propagation from whenAll's final child callback. Drain affected instances before changing
interval settings, mixing versions, or rolling back.

## v0.4.0 (2026-07-31)

### Changes
Expand Down
41 changes: 41 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,47 @@ const entityResponseBytes = await worker.processEntityBatchRequest(entityBatchRe

`TaskHubGrpcClient` already exposes orchestration start/query/event/terminate/suspend/resume/purge APIs and entity signal/read/query/clean APIs through its existing `hostAddress` and `metadataGenerator` options. Host integrations that need task-hub routing metadata should provide it through `metadataGenerator`, keeping host-specific metadata policy outside the core client. Azure-managed scheduler connection strings remain in `@microsoft/durabletask-js-azuremanaged`.

## Long durable timers

`createTimer(Date | seconds)` has no SDK-imposed total-duration cap. Like the Python SDK,
core workers default to three-day backend segments, including durable retry delays. A ten-day
timer uses 3 + 3 + 3 + 1 day segments but remains one logical `TimerTask`.

| Entry point | Timer behavior |
| --- | --- |
| Core `TaskHubGrpcWorker` / `TestOrchestrationWorker` | Three-day default |
| Azure-managed worker builder | Explicitly native timers; DTS supports long timers |
| `durable-functions` worker / `runOrchestrator` | Inherits the core three-day default |

Core `TaskHubGrpcWorker({ maximumTimerIntervalMs })` and
`TestOrchestrationWorker(backend, { maximumTimerIntervalMs })` accept the Python-equivalent
interval override in milliseconds. Omit it for three days; `null`, zero, or negative values
disable segmentation. Values must be finite. Python's `timedelta` supports microseconds;
JavaScript `Date` supports milliseconds, so positive fractions are rounded up to whole milliseconds.
Functions exposes no timer configuration and also segments when connected to DTS, as in Python;
there is no backend detection. Native in-memory timers still have Node.js's approximately
24.9-day timeout limit when segmentation is disabled.

**Cancellation change:** `timer.cancel()` now returns `true` on first cancellation and `false`
when already terminal. It removes the current segment, marks the timer canceled and complete
(`isCanceled`, `isComplete`, `isCompleted`), and notifies its parent; cancellation is not failure.
`timer.getResult()` and `timer.result` throw `TaskCancelledError` after cancellation. For timers,
`result` now aliases `getResult()` even while pending or failed. A canceled timer can win `whenAny`;
inspect `isCanceled` before reading its result. `whenAll` counts cancellation as terminal and
propagates the error when collecting final child results, which can throw from `cancel()` or
a sibling's completion callback. Do not yield a canceled timer expecting success.
If that callback throws, do not catch it and reuse the `whenAll` group or its parents:
the group can already be marked complete without a result and without notifying its parent.
Like Python, `getResult()` rejects this uninitialized result instead of treating it as success.
Parent notification is not resumed after the callback exception.
Custom `Task` subclasses now have their completed `getResult()` accessor called on each yield
instead of reading the raw result field. Accessors must be replay-safe; thrown errors fail execution.

**Rollout:** both the core default and cancellation semantics intentionally change to match Python.
Existing single native timer histories replay at their recorded final deadline, but changed
cancellation branching can affect replay. Avoid mixed versions; drain affected instances or use
a new task hub before changing intervals, rolling back, or switching providers.

## npm packages

The following npm packages are available for download.
Expand Down
10 changes: 10 additions & 0 deletions packages/azure-functions-durable/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,16 @@

### Fixes

- Inherit Python-aligned core three-day timer/retry segments so the gRPC provider does not exceed
Azure Storage's per-message delay limit. The testing helper inherits the same default. Functions
also splits timers with DTS; there is no backend detection or Functions timer configuration.

### Breaking changes

- Timer cancellation now matches Python: boolean return, canceled terminal state, parent notification,
and `TaskCancelledError` from canceled results. Timer `result` now aliases `getResult()`, including
errors while pending or failed. Drain affected instances before mixing versions or rollback;
cancellation branching and already-segmented histories can change replay.
## v4.0.0-beta.1 (2026-07-31)

### Changes
Expand Down
30 changes: 30 additions & 0 deletions packages/azure-functions-durable/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,36 @@ app.http("startHello", {
});
```

## Long durable timers

Both core-native `ctx.createTimer(...)` and classic `context.df.createTimer(...)` use
the Python-aligned core three-day default automatically, including durable retry delays. This allows, for example,
a 30-day business timer on Azure Storage without sending a queue visibility delay over its
seven-day limit. The gRPC host route does not apply the legacy host-side timer splitting;
segmentation happens in the core SDK instead. There is no SDK cap on the total timer duration.
The returned `TimerTask` keeps its identity across segments and completes normally only at the final deadline.

No application configuration is needed. Functions uses these segments even with DTS, because
backend capabilities are not automatically detected. The Azure-managed worker builder instead
explicitly uses native timers. `runOrchestrator` and direct core test workers share the three-day
default. Functions does not expose a timer interval override. Tests still wait in real time
unless using the test runner's clock controls.

**Cancellation now matches Python:** `cancel()` returns `true` on first cancellation (`false`
if already terminal), removes the current segment, and marks the timer canceled and complete,
not failed. `getResult()` and `result` throw the exported `TaskCancelledError` when canceled;
timer `result` also throws while pending or failed. A canceled timer can win `Task.any`; check
`isCanceled` before reading its result. `Task.all` waits for every child and propagates cancellation
while collecting final results, including from the final completion/cancel callback.
Do not catch that callback exception and reuse the `Task.all` group or its parents: result
collection and parent notification may not have finished even though the group is marked
complete. `getResult()` rejects that uninitialized result; parent notification is not resumed.

**Rollout/rollback:** existing single native timer histories replay at their recorded final
deadline, but branching on the new cancellation state can change replay. Do not mix old and new
workers for affected instances or roll back segmented histories to native timers. Drain them or
use a new task hub first.

## Testing

`durable-functions/testing` provides one helper for the common case — running an orchestrator to
Expand Down
2 changes: 1 addition & 1 deletion packages/azure-functions-durable/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ export {
// the classic durable-functions v3 top-level `TaskFailedError` export. (`DurableError` /
// `AggregatedError` were never v3 top-level exports; the core engine surfaces `TaskFailedError` and
// aggregate failures as JS-native `AggregateError`.) See the package README/CHANGELOG migration notes.
export { TaskFailedError } from "@microsoft/durabletask-js";
export { TaskFailedError, TaskCancelledError } from "@microsoft/durabletask-js";
export {
DurableOrchestrationContext,
ClassicOrchestrationContext,
Expand Down
4 changes: 2 additions & 2 deletions packages/azure-functions-durable/src/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@
import { TaskHubGrpcWorker, TaskHubGrpcWorkerOptions } from "@microsoft/durabletask-js";

export class DurableFunctionsWorker extends TaskHubGrpcWorker {
constructor(options: TaskHubGrpcWorkerOptions = {}) {
super(options);
constructor(options: Omit<TaskHubGrpcWorkerOptions, "maximumTimerIntervalMs"> = {}) {
super({ ...options, maximumTimerIntervalMs: undefined });
}

async handleOrchestratorRequest(encodedRequest: string): Promise<string> {
Expand Down
31 changes: 31 additions & 0 deletions packages/azure-functions-durable/test/unit/app.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,41 @@
// Licensed under the MIT License.

import { GenericFunctionOptions, InvocationContext, app as azFuncApp } from "@azure/functions";
import { OrchestrationContext } from "@microsoft/durabletask-js";
import * as app from "../../src/app";
import { DurableFunctionsWorker } from "../../src/worker";
import * as pb from "../../../durabletask-js/src/proto/orchestrator_service_pb";
import * as ph from "../../../durabletask-js/src/utils/pb-helper.util";

describe("app registration", () => {
it("requires no timer setup API", () => {
expect(app).not.toHaveProperty("setup");
});

it("automatically splits long timers in the normal app registration path", async () => {
const start = new Date("2026-01-01T00:00:00Z");
const day = 86400000;
app.orchestration("long-timer", async function* (ctx: OrchestrationContext) {
yield ctx.createTimer((30 * day) / 1000);
});
const request = new pb.OrchestratorRequest();
request.setInstanceid("instance");
request.setNeweventsList([
ph.newOrchestratorStartedEvent(start),
ph.newExecutionStartedEvent("long-timer", "instance"),
]);
const { options } = lastRegistration();
const encodedResponse = await options.handler(
Buffer.from(request.serializeBinary()).toString("base64"),
{} as InvocationContext,
);
const actions = pb.OrchestratorResponse.deserializeBinary(
Buffer.from(encodedResponse as string, "base64"),
).getActionsList();
expect(actions).toHaveLength(1);
expect(actions[0].getCreatetimer()?.getFireat()?.toDate()).toEqual(new Date(start.getTime() + 3 * day));
});

let genericSpy: jest.SpyInstance;

beforeEach(() => {
Expand Down
67 changes: 67 additions & 0 deletions packages/azure-functions-durable/test/unit/testing.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,43 @@ import {
InMemoryOrchestrationBackend,
TestOrchestrationClient,
TestOrchestrationWorker,
TOrchestrator,
whenAny,
} from "@microsoft/durabletask-js";
import { OrchestrationRuntimeStatus, toDurableOrchestrationStatus, wrapOrchestrator } from "../../src";
import type { OrchestrationContext, OrchestrationHandler } from "../../src";
import { createActivityContext, runOrchestrator } from "../../src/testing";

describe("durable-functions/testing", () => {
it("uses the same three-day default in the standalone core test worker", async () => {
const backend = new InMemoryOrchestrationBackend();
const worker = new TestOrchestrationWorker(backend);
const client = new TestOrchestrationClient(backend);
const complete = jest.spyOn(backend, "completeOrchestration");
const day = 24 * 60 * 60 * 1000;
let startedAt = 0;
worker.addNamedOrchestrator("native-timer", async function* (ctx) {
startedAt = ctx.currentUtcDateTime.getTime();
const timer = ctx.createTimer((30 * day) / 1000);
yield whenAny([timer, ctx.callActivity("approve")]);
timer.cancel();
return "approved";
});
worker.addNamedActivity("approve", async () => "ok");
await worker.start();
try {
const id = await client.scheduleNewOrchestration("native-timer");
await client.waitForOrchestrationCompletion(id);
const timers = complete.mock.calls.flatMap((call) => call[2]).filter((action) => action.hasCreatetimer());
expect(timers).toHaveLength(1);
expect(timers[0].getCreatetimer()?.getFireat()?.toDate().getTime()).toBe(startedAt + 3 * day);
} finally {
await worker.stop();
backend.reset();
complete.mockRestore();
}
});

describe("createActivityContext", () => {
it("builds the invocation context an activity handler receives", async () => {
const sayHello = (name: string, context: InvocationContext) => `${context.functionName}: Hello, ${name}!`;
Expand All @@ -22,6 +53,42 @@ describe("durable-functions/testing", () => {
});

describe("runOrchestrator", () => {
it("inherits the core three-day timer default, like the Functions worker", async () => {
const complete = jest.spyOn(InMemoryOrchestrationBackend.prototype, "completeOrchestration");
const day = 24 * 60 * 60 * 1000;
let startedAt = 0;
const orchestrator: TOrchestrator = async function* (ctx) {
startedAt = ctx.currentUtcDateTime.getTime();
const timer = ctx.createTimer((30 * day) / 1000);
yield whenAny([timer, ctx.callActivity("approve")]);
timer.cancel();
return "approved";
};
try {
expect((await runOrchestrator(orchestrator, { activities: { approve: () => "ok" } })).output).toBe("approved");
const timers = complete.mock.calls.flatMap((call) => call[2]).filter((action) => action.hasCreatetimer());
expect(timers).toHaveLength(1);
expect(timers[0].getCreatetimer()?.getFireat()?.toDate().getTime()).toBe(startedAt + 3 * day);
} finally {
complete.mockRestore();
}
});

it("keeps short timers as a single segment", async () => {
const complete = jest.spyOn(InMemoryOrchestrationBackend.prototype, "completeOrchestration");
const orchestrator: TOrchestrator = async function* (ctx) {
yield ctx.createTimer(0.015);
return "elapsed";
};
try {
expect((await runOrchestrator(orchestrator)).output).toBe("elapsed");
const timers = complete.mock.calls.flatMap((call) => call[2]).filter((action) => action.hasCreatetimer());
expect(timers).toHaveLength(1);
} finally {
complete.mockRestore();
}
});

it("runs a classic orchestrator against inline activities", async () => {
const orchestrator: OrchestrationHandler = function* (
context: OrchestrationContext,
Expand Down
Loading
Loading