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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

### New

- Add optional version arguments to orchestrator/activity registrations in core and test workers,
dispatch by recorded/request version, and preserve versions in the in-memory backend. Same-name
versions coexist; unversioned-only registrations retain the .NET-compatible fallback.
- 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.
Expand All @@ -22,6 +25,8 @@

### Fixes

- Preserve explicit empty activity and child versions on the protobuf wire, including the
unversioned child default, so concrete empty-version work-item filters can match them.
- 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
Expand All @@ -40,6 +45,12 @@

### Breaking changes

- Activity calls without an explicit version now inherit the parent's instance version, and
child calls inherit the worker's `versioning.defaultVersion`; explicit `""` selects unversioned.
Worker acceptance checks now also apply to activities; `Strict` with an empty worker version
filters for unversioned work. Adding a versioned registration disables that name's unversioned
catch-all. Missing registrations are non-retriable. See README migration guidance before
changing defaults or upgrading workers with in-flight versioned instances.
- 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
Expand Down
80 changes: 80 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,86 @@ const purchaseOrderWorkflow: TOrchestrator = async function* (ctx: Orchestration

You can find the full sample at [examples/hello-world/human_interaction.ts](./examples/hello-world/human_interaction.ts).

### Versioned registration and dispatch

One worker can host multiple implementations of the same orchestrator or activity name:

```typescript
import { TaskHubGrpcClient, TaskHubGrpcWorker } from "@microsoft/durabletask-js";

const worker = new TaskHubGrpcWorker({
versioning: { defaultVersion: "v2" },
workItemFilters: "auto",
});
worker.addNamedOrchestrator("Order", async function* (ctx) {
yield ctx.callActivity("Price");
}, "v1");
worker.addNamedOrchestrator("Order", async function* (ctx) {
yield ctx.callActivity("Price");
yield ctx.callSubOrchestrator("Audit");
}, "v2");
worker.addNamedActivity("Price", () => 100, "v1");
worker.addNamedActivity("Price", () => 120, "v2");
worker.addNamedOrchestrator("Audit", () => "audited", "v2");
await worker.start();

const client = new TaskHubGrpcClient();
await client.scheduleNewOrchestration("Order", undefined, { version: "v1" });
await client.scheduleNewOrchestration("Order", undefined, { version: "v2" });
```

`addOrchestrator(fn, version?)` and `addActivity(fn, version?)` retain the function's native name;
the named variants accept `(name, fn, version?)`. These signatures also apply to
`TestOrchestrationWorker` and `DurableTaskAzureManagedWorkerBuilder`. Entities remain name-only.

Versions are case-insensitive opaque strings: `"V1"` equals `"v1"`, but `"1"` and `"1.0"` are
different registrations. Omitted, `undefined`, `null` (JavaScript), and `""` versions identify the
unversioned registration. Whitespace-only registration versions are rejected; other strings are
not trimmed. Duplicate name/version pairs throw. **Task names remain case-sensitive in JavaScript**,
unlike .NET; this preserves existing JavaScript identifiers and function-name behavior.
Backend constraints still apply: Azure DTS accepts numeric `Major[.Minor[.Patch]]` orchestration
versions, so use values such as `"1.0.0"` and `"2.0.0"` rather than `"v1"` and `"v2"` with that service.

Dispatch uses the recorded `ExecutionStarted.version` during both initial execution and replay,
and `ActivityRequest.version` for activities. Exact matches win. Following the
[.NET factory contract](https://github.com/microsoft/durabletask-dotnet/blob/92474e9e35c66d64de36cabd0a17652376d37873/src/Worker/Core/DurableTaskFactory.cs),
a name with **only an unversioned registration** can handle any request version. Adding any
versioned registration for that name disables this fallback: unknown versions fail with the
existing not-registered error (including the requested version), marked non-retriable. An
unversioned request never selects a versioned implementation.

| Scheduled work | When `options.version` is omitted | Explicit `version: ""` |
| --- | --- | --- |
| Activity | Current orchestration instance's `ctx.version` | Unversioned |
| Sub-orchestration | Worker's `versioning.defaultVersion`, or unversioned if unset | Unversioned |
| Top-level orchestration | Client's `defaultVersion`, or unversioned if unset | Unversioned |

An explicit nonempty version overrides these defaults. Activities do **not** inherit the worker
default, and children do **not** inherit the parent's version. Both policy and handler retries
retain the originally selected version. Worker defaults do not change `continueAsNew` behavior.
The in-memory client accepts the start `version` option; its worker accepts
`{ versioning: { defaultVersion: "v2" } }`, but does not emulate gRPC worker acceptance/rejection.

**Acceptance policy is separate from implementation lookup.** `Strict` and `CurrentOrOlder`
apply before dispatch to both orchestrations and activities; registrations cannot bypass them.
`Strict` with no worker version accepts only unversioned work. An omitted `matchStrategy` means
`None`. `Reject` abandons mismatched work; `Fail` returns an explicit non-retriable failure.
Auto filters contain one entry per logical name: all registered versions (including `""` for
mixed registrations), or a wildcard for unversioned-only names. Under `Strict`, filters use the
configured worker version, including `""`. Explicit filters are unchanged.

**Migration:** previous JavaScript workers ignored activity request versions and always scheduled
unspecified activities as unversioned. Versioned parents now pass their version to activities;
use `{ version: "" }` to keep an activity unversioned. Setting a worker child default now affects
unspecified child calls; use explicit child versions for stable routing. Keep implementations for
all in-flight versions, and drain affected instances before changing replay-sensitive defaults or
mixing old and new workers. Adding the first versioned registration removes the name's legacy
catch-all, so register every version that still needs to run.

Azure Functions `app.orchestration` / `app.activity` remain host registrations with unique function
names, not a multi-version host-routing API. The embedded `DurableFunctionsWorker` inherits core
version dispatch and child defaults for integrations that supply versioned protobuf requests.

### Continue as new

Long-running orchestrations can restart with fresh history and optionally move to a new
Expand Down
3 changes: 3 additions & 0 deletions packages/azure-functions-durable/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@

### Fixes

- Inherit core version-aware replay dispatch and worker child defaults in the embedded
`DurableFunctionsWorker`, including classic-context wrappers. Host `app.*` function registrations
remain name-only; see README for the boundary and activity-version migration guidance.
- 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.
Expand Down
14 changes: 14 additions & 0 deletions packages/azure-functions-durable/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,20 @@ This package supersedes the legacy [`durable-functions`](https://github.com/Azur
- **Client** — `getClient(context)` returns a `DurableFunctionsClient` for scheduling, querying, signaling, and managing instances, plus HTTP management-payload helpers (`createCheckStatusResponse`, `createHttpManagementPayload`) for durable HTTP starters. The `app.client.*` starter helpers (`http`, `timer`, `storageBlob`, `storageQueue`, `serviceBusQueue`, `serviceBusTopic`, `eventHub`, `eventGrid`, `cosmosDB`, `generic`) register a normal trigger and inject the client as the handler's second argument, so `(trigger, client, context)` works without manually wiring `df.input.durableClient()` + `df.getClient(context)`.
- **Classic (v3) compatibility** — orchestrators and entities written in the legacy `context.df.*` style, `RetryOptions`, `EntityId`, and the deprecated client aliases are adapted onto the core engine.

### Versioning boundary

`app.orchestration` and `app.activity` register **unique Azure Function names**. They do not expose
multiple same-name versions to the Functions host; activities are dispatched by the host, not by
the core worker's activity registry. Existing app registrations remain unversioned catch-alls.

For embedded integrations using `DurableFunctionsWorker` directly, the inherited core registration
methods accept an optional version and dispatch using the request's recorded version. Its constructor
accepts `versioning.defaultVersion` for child scheduling, including through the classic context
wrapper. Explicit child `version: ""` (the classic method's final argument) selects unversioned.
This low-level capability does not add a multi-version Functions host registration protocol.
See the core [versioning contract and migration guidance](../../README.md#versioned-registration-and-dispatch);
unspecified activities now carry their parent's instance version on the wire.

## Migrating from durable-functions v3

This provider keeps classic `context.df.*` orchestrators and entities working, but a few surfaces
Expand Down
36 changes: 36 additions & 0 deletions packages/azure-functions-durable/test/unit/worker.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,42 @@ async function timerActions(worker: DurableFunctionsWorker) {
}

describe("DurableFunctionsWorker", () => {
it("dispatches versioned registrations and child defaults through the classic wrapper", async () => {
const worker = new DurableFunctionsWorker({
logger: new NoOpLogger(),
versioning: { defaultVersion: "child-default" },
});
for (const version of ["v1", "v2"]) {
worker.addNamedOrchestrator(
"Flow",
wrapOrchestrator(function* (ctx: ClassicOrchestrationContext) {
expect(ctx.df.version).toBe(version);
ctx.df.callSubOrchestrator("Child");
ctx.df.callSubOrchestrator("Child", undefined, undefined, "");
yield ctx.df.callSubOrchestrator("Child", undefined, undefined, "explicit");
}),
version,
);
}
for (const version of ["v1", "v2"]) {
const request = new pb.OrchestratorRequest()
.setInstanceid("instance")
.setNeweventsList([
ph.newOrchestratorStartedEvent(START),
ph.newExecutionStartedEvent("Flow", "instance", undefined, undefined, undefined, version),
]);
const response = await worker.handleOrchestratorRequest(
Buffer.from(request.serializeBinary()).toString("base64"),
);
const actions = pb.OrchestratorResponse.deserializeBinary(Buffer.from(response, "base64")).getActionsList();
expect(actions.map((action) => action.getCreatesuborchestration()?.getVersion()?.getValue() ?? "")).toEqual([
"child-default",
"",
"explicit",
]);
}
});

it("inherits the core default without a Functions timer configuration surface", async () => {
const options = { logger: new NoOpLogger(), maximumTimerIntervalMs: null };
const worker = new DurableFunctionsWorker(options);
Expand Down
2 changes: 2 additions & 0 deletions packages/durabletask-js-azuremanaged/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

### New

- Add an optional final version argument to all orchestrator/activity builder registrations,
preserving same-name versions and version-aware auto filters in the built worker.
- Add `DurableTaskAzureManagedWorkerBuilder.silentDisconnectTimeout()` to configure the
core worker's work-item stream watchdog. Set it to `0` for sidecars that do not emit
health-ping work items, including the current durabletask-go sidecar.
Expand Down
22 changes: 22 additions & 0 deletions packages/durabletask-js-azuremanaged/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,28 @@ const worker = createAzureManagedWorkerBuilder("https://myservice.durabletask.io
await worker.start();
```

### Versioned registrations

All four orchestrator/activity registration methods accept an optional final `version` argument,
which is preserved when the builder constructs the worker:

```typescript
const worker = createAzureManagedWorkerBuilder("http://localhost:8080", "myTaskHub", null)
.versioning({ defaultVersion: "v2" })
.addNamedOrchestrator("Order", orderV1, "v1")
.addNamedOrchestrator("Order", orderV2, "v2")
.addNamedActivity("Price", priceV1, "v1")
.addNamedActivity("Price", priceV2, "v2")
.useWorkItemFilters()
.build();
```

Names remain case-sensitive; versions are case-insensitive opaque strings. Omitted or empty
versions register an unversioned implementation. Worker filtering is independent of local
dispatch. The worker default applies to child orchestrations, while activities default to their
parent instance's version. See the core
[versioning and migration contract](../../README.md#versioned-registration-and-dispatch).

## Supported Authentication Types

The connection string `Authentication` parameter supports the following values:
Expand Down
40 changes: 24 additions & 16 deletions packages/durabletask-js-azuremanaged/src/worker-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@ import {
export class DurableTaskAzureManagedWorkerBuilder {
private _options: DurableTaskAzureManagedWorkerOptions;
private _grpcChannelOptions: grpc.ChannelOptions = {};
private _orchestrators: { name?: string; fn: TOrchestrator }[] = [];
private _activities: { name?: string; fn: TActivity<TInput, TOutput> }[] = [];
private _orchestrators: { name?: string; fn: TOrchestrator; version?: string }[] = [];
private _activities: { name?: string; fn: TActivity<TInput, TOutput>; version?: string }[] = [];
private _entities: { name?: string; factory: EntityFactory }[] = [];
private _logger: Logger = new ConsoleLogger();
private _shutdownTimeoutMs?: number;
Expand Down Expand Up @@ -148,10 +148,11 @@ export class DurableTaskAzureManagedWorkerBuilder {
* Registers an orchestrator function with the worker.
*
* @param fn The orchestrator function.
* @param version Registration version; omitted or empty means unversioned.
* @returns This builder instance.
*/
addOrchestrator(fn: TOrchestrator): DurableTaskAzureManagedWorkerBuilder {
this._orchestrators.push({ fn });
addOrchestrator(fn: TOrchestrator, version?: string): DurableTaskAzureManagedWorkerBuilder {
this._orchestrators.push({ fn, version });
return this;
}

Expand All @@ -160,21 +161,23 @@ export class DurableTaskAzureManagedWorkerBuilder {
*
* @param name The name of the orchestrator.
* @param fn The orchestrator function.
* @param version Registration version; omitted or empty means unversioned.
* @returns This builder instance.
*/
addNamedOrchestrator(name: string, fn: TOrchestrator): DurableTaskAzureManagedWorkerBuilder {
this._orchestrators.push({ name, fn });
addNamedOrchestrator(name: string, fn: TOrchestrator, version?: string): DurableTaskAzureManagedWorkerBuilder {
this._orchestrators.push({ name, fn, version });
return this;
}

/**
* Registers an activity function with the worker.
*
* @param fn The activity function.
* @param version Registration version; omitted or empty means unversioned.
* @returns This builder instance.
*/
addActivity(fn: TActivity<TInput, TOutput>): DurableTaskAzureManagedWorkerBuilder {
this._activities.push({ fn });
addActivity(fn: TActivity<TInput, TOutput>, version?: string): DurableTaskAzureManagedWorkerBuilder {
this._activities.push({ fn, version });
return this;
}

Expand All @@ -183,10 +186,15 @@ export class DurableTaskAzureManagedWorkerBuilder {
*
* @param name The name of the activity.
* @param fn The activity function.
* @param version Registration version; omitted or empty means unversioned.
* @returns This builder instance.
*/
addNamedActivity(name: string, fn: TActivity<TInput, TOutput>): DurableTaskAzureManagedWorkerBuilder {
this._activities.push({ name, fn });
addNamedActivity(
name: string,
fn: TActivity<TInput, TOutput>,
version?: string,
): DurableTaskAzureManagedWorkerBuilder {
this._activities.push({ name, fn, version });
return this;
}

Expand Down Expand Up @@ -331,20 +339,20 @@ export class DurableTaskAzureManagedWorkerBuilder {
});

// Register all orchestrators
for (const { name, fn } of this._orchestrators) {
for (const { name, fn, version } of this._orchestrators) {
if (name) {
worker.addNamedOrchestrator(name, fn);
worker.addNamedOrchestrator(name, fn, version);
} else {
worker.addOrchestrator(fn);
worker.addOrchestrator(fn, version);
}
}

// Register all activities
for (const { name, fn } of this._activities) {
for (const { name, fn, version } of this._activities) {
if (name) {
worker.addNamedActivity(name, fn);
worker.addNamedActivity(name, fn, version);
} else {
worker.addActivity(fn);
worker.addActivity(fn, version);
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,38 @@ describe("DurableTaskAzureManagedWorkerBuilder", () => {
const ENDPOINT = "http://localhost:8080";
const TASKHUB = "test";

it("preserves named and inferred version registrations and the child default on build", async () => {
const activity = () => "activity";
const flow = async function* (ctx: import("@microsoft/durabletask-js").OrchestrationContext) {
yield ctx.callSubOrchestrator("Child");
};
const builder = new DurableTaskAzureManagedWorkerBuilder()
.endpoint(ENDPOINT, TASKHUB, null)
.versioning({ defaultVersion: "child" });
for (const version of ["v1", "v2"]) {
builder.addOrchestrator(flow, version).addNamedOrchestrator("Named", flow, version);
builder.addActivity(activity, version).addNamedActivity("Named", activity, version);
}
const worker = builder.build();
for (const name of ["flow", "Named"]) {
for (const version of ["v1", "v2"]) {
const request = new pb.OrchestratorRequest()
.setInstanceid("instance")
.setNeweventsList([ph.newExecutionStartedEvent(name, "instance", undefined, undefined, undefined, version)]);
const response = pb.OrchestratorResponse.deserializeBinary(
await worker.processOrchestratorRequest(request.serializeBinary()),
);
expect(response.getActionsList()[0].getCreatesuborchestration()?.getVersion()?.getValue()).toBe("child");
}
}
for (const name of ["activity", "Named"]) {
for (const version of ["v1", "v2"]) {
expect(worker["_registry"].getActivity(name, version)).toBe(activity);
}
}
expect(() => builder.addActivity(activity, "v1").build()).toThrow(/already exists/);
});

it("keeps DTS timers native at their full deadline", async () => {
const start = new Date("2026-01-01T00:00:00Z");
const deadline = new Date("2026-01-31T00:00:00Z");
Expand Down
Loading
Loading