Skip to content

Commit d0cf4e7

Browse files
committed
feat: Implement versioning support with default version handling and version comparison methods
1 parent 6e59303 commit d0cf4e7

File tree

10 files changed

+429
-12
lines changed

10 files changed

+429
-12
lines changed

packages/durabletask-js/src/client/client.ts

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,12 +49,19 @@ export interface TaskHubGrpcClientOptions {
4949
metadataGenerator?: MetadataGenerator;
5050
/** Optional logger instance. Defaults to ConsoleLogger. */
5151
logger?: Logger;
52+
/**
53+
* The default version to use when starting new orchestrations without an explicit version.
54+
* If specified, this will be used as the version for orchestrations that don't provide
55+
* their own version in StartOrchestrationOptions.
56+
*/
57+
defaultVersion?: string;
5258
}
5359

5460
export class TaskHubGrpcClient {
5561
private _stub: stubs.TaskHubSidecarServiceClient;
5662
private _metadataGenerator?: MetadataGenerator;
5763
private _logger: Logger;
64+
private _defaultVersion?: string;
5865

5966
/**
6067
* Creates a new TaskHubGrpcClient instance.
@@ -97,6 +104,7 @@ export class TaskHubGrpcClient {
97104
let resolvedCredentials: grpc.ChannelCredentials | undefined;
98105
let resolvedMetadataGenerator: MetadataGenerator | undefined;
99106
let resolvedLogger: Logger | undefined;
107+
let resolvedDefaultVersion: string | undefined;
100108

101109
if (typeof hostAddressOrOptions === "object" && hostAddressOrOptions !== null) {
102110
// Options object constructor
@@ -106,6 +114,7 @@ export class TaskHubGrpcClient {
106114
resolvedCredentials = hostAddressOrOptions.credentials;
107115
resolvedMetadataGenerator = hostAddressOrOptions.metadataGenerator;
108116
resolvedLogger = hostAddressOrOptions.logger;
117+
resolvedDefaultVersion = hostAddressOrOptions.defaultVersion;
109118
} else {
110119
// Deprecated positional parameters constructor
111120
resolvedHostAddress = hostAddressOrOptions;
@@ -119,6 +128,7 @@ export class TaskHubGrpcClient {
119128
this._stub = new GrpcClient(resolvedHostAddress, resolvedOptions, resolvedUseTLS, resolvedCredentials).stub;
120129
this._metadataGenerator = resolvedMetadataGenerator;
121130
this._logger = resolvedLogger ?? new ConsoleLogger();
131+
this._defaultVersion = resolvedDefaultVersion;
122132
}
123133

124134
async stop(): Promise<void> {
@@ -184,6 +194,9 @@ export class TaskHubGrpcClient {
184194
? undefined
185195
: instanceIdOrOptions.version;
186196

197+
// Use provided version, or fall back to client's default version
198+
const effectiveVersion = version ?? this._defaultVersion;
199+
187200
const req = new pb.CreateInstanceRequest();
188201
req.setName(name);
189202
req.setInstanceid(instanceId ?? randomUUID());
@@ -197,15 +210,15 @@ export class TaskHubGrpcClient {
197210
req.setInput(i);
198211
req.setScheduledstarttimestamp(ts);
199212

200-
if (version) {
213+
if (effectiveVersion) {
201214
const v = new StringValue();
202-
v.setValue(version);
215+
v.setValue(effectiveVersion);
203216
req.setVersion(v);
204217
}
205218

206219
populateTagsMap(req.getTagsMap(), tags);
207220

208-
this._logger.info(`Starting new ${name} instance with ID = ${req.getInstanceid()}${version ? ` (version: ${version})` : ''}`);
221+
this._logger.info(`Starting new ${name} instance with ID = ${req.getInstanceid()}${effectiveVersion ? ` (version: ${effectiveVersion})` : ''}`);
209222

210223
const res = await callWithMetadata<pb.CreateInstanceRequest, pb.CreateInstanceResponse>(
211224
this._stub.startInstance.bind(this._stub),

packages/durabletask-js/src/task/context/orchestration-context.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { Logger } from "../../types/logger.type";
77
import { ReplaySafeLogger } from "../../types/replay-safe-logger";
88
import { TaskOptions, SubOrchestrationOptions } from "../options";
99
import { Task } from "../task";
10+
import { compareVersions } from "../../utils/versioning.util";
1011

1112
export abstract class OrchestrationContext {
1213
/**
@@ -51,6 +52,35 @@ export abstract class OrchestrationContext {
5152
*/
5253
abstract get version(): string;
5354

55+
/**
56+
* Compares the current orchestration version to the specified version.
57+
*
58+
* This method uses semantic versioning comparison when both versions are valid
59+
* semantic versions, and falls back to lexicographic comparison otherwise.
60+
*
61+
* @remarks
62+
* - If both versions are empty, this returns 0 (equal).
63+
* - An empty context version is considered less than a defined version.
64+
* - An empty parameter version is considered less than a defined context version.
65+
*
66+
* @param {string} version The version to compare against.
67+
* @returns {number} A negative number if context version < parameter version,
68+
* zero if equal, positive if context version > parameter version.
69+
*
70+
* @example
71+
* ```typescript
72+
* const orchestrator: TOrchestrator = async function* (ctx, input) {
73+
* if (ctx.compareVersionTo("2.0.0") >= 0) {
74+
* // This orchestration is version 2.0.0 or newer
75+
* yield ctx.callActivity(newFeature, input);
76+
* }
77+
* };
78+
* ```
79+
*/
80+
compareVersionTo(version: string): number {
81+
return compareVersions(this.version, version);
82+
}
83+
5484
/**
5585
* Create a timer task that will fire at a specified time.
5686
*

packages/durabletask-js/src/utils/pb-helper.util.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,22 @@ export function newFailureDetails(e: any): pb.TaskFailureDetails {
181181
return failure;
182182
}
183183

184+
/**
185+
* Creates a TaskFailureDetails for version mismatch errors.
186+
* These errors are non-retriable as the version mismatch is deterministic.
187+
*
188+
* @param errorType The type of version error (e.g., "VersionMismatch", "VersionError")
189+
* @param errorMessage The error message describing the version mismatch
190+
* @returns A TaskFailureDetails with IsNonRetriable set to true
191+
*/
192+
export function newVersionMismatchFailureDetails(errorType: string, errorMessage: string): pb.TaskFailureDetails {
193+
const failure = new pb.TaskFailureDetails();
194+
failure.setErrortype(errorType);
195+
failure.setErrormessage(errorMessage);
196+
failure.setIsnonretriable(true);
197+
return failure;
198+
}
199+
184200
export function newEventRaisedEvent(name: string, encodedInput?: string): pb.HistoryEvent {
185201
const ts = new Timestamp();
186202

packages/durabletask-js/src/worker/task-hub-grpc-worker.ts

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -415,6 +415,8 @@ export class TaskHubGrpcWorker {
415415
compatible: boolean;
416416
shouldFail: boolean;
417417
orchestrationVersion?: string;
418+
errorType?: string;
419+
errorMessage?: string;
418420
} {
419421
// If no versioning options configured or match strategy is None, always compatible
420422
if (!this._versioning || this._versioning.matchStrategy === VersionMatchStrategy.None) {
@@ -431,11 +433,16 @@ export class TaskHubGrpcWorker {
431433
}
432434

433435
let compatible = false;
436+
let errorType = "VersionMismatch";
437+
let errorMessage = "";
434438

435439
switch (this._versioning.matchStrategy) {
436440
case VersionMatchStrategy.Strict:
437-
// Only process if versions match exactly
438-
compatible = orchestrationVersion === workerVersion;
441+
// Only process if versions match (using semantic comparison)
442+
compatible = compareVersions(orchestrationVersion, workerVersion) === 0;
443+
if (!compatible) {
444+
errorMessage = `The orchestration version '${orchestrationVersion ?? ""}' does not match the worker version '${workerVersion}'.`;
445+
}
439446
break;
440447

441448
case VersionMatchStrategy.CurrentOrOlder:
@@ -445,16 +452,23 @@ export class TaskHubGrpcWorker {
445452
compatible = true;
446453
} else {
447454
compatible = compareVersions(orchestrationVersion, workerVersion) <= 0;
455+
if (!compatible) {
456+
errorMessage = `The orchestration version '${orchestrationVersion}' is greater than the worker version '${workerVersion}'.`;
457+
}
448458
}
449459
break;
450460

451461
default:
452-
compatible = true;
462+
// Unknown match strategy - treat as version error
463+
compatible = false;
464+
errorType = "VersionError";
465+
errorMessage = `The version match strategy '${this._versioning.matchStrategy}' is unknown.`;
466+
break;
453467
}
454468

455469
if (!compatible) {
456470
const shouldFail = this._versioning.failureStrategy === VersionFailureStrategy.Fail;
457-
return { compatible: false, shouldFail, orchestrationVersion };
471+
return { compatible: false, shouldFail, orchestrationVersion, errorType, errorMessage };
458472
}
459473

460474
return { compatible: true, shouldFail: false };
@@ -511,18 +525,20 @@ export class TaskHubGrpcWorker {
511525
if (versionCheckResult.shouldFail) {
512526
// Fail the orchestration with version mismatch error
513527
this._logger.warn(
514-
`Version mismatch for instance '${instanceId}': orchestration version '${versionCheckResult.orchestrationVersion}' does not match worker version '${this._versioning?.version}'. Failing orchestration.`,
528+
`${versionCheckResult.errorType} for instance '${instanceId}': ${versionCheckResult.errorMessage}. Failing orchestration.`,
515529
);
516530

517-
const failureDetails = pbh.newFailureDetails(
518-
new Error(`Version mismatch: orchestration version '${versionCheckResult.orchestrationVersion}' is not compatible with worker version '${this._versioning?.version}'`),
531+
const failureDetails = pbh.newVersionMismatchFailureDetails(
532+
versionCheckResult.errorType!,
533+
versionCheckResult.errorMessage!,
519534
);
520535

521536
const actions = [
522537
pbh.newCompleteOrchestrationAction(
523538
-1,
524539
pb.OrchestrationStatus.ORCHESTRATION_STATUS_FAILED,
525-
failureDetails?.toString(),
540+
undefined,
541+
failureDetails,
526542
),
527543
];
528544

@@ -540,7 +556,7 @@ export class TaskHubGrpcWorker {
540556
} else {
541557
// Reject the work item - explicitly abandon it so it can be picked up by another worker
542558
this._logger.info(
543-
`Version mismatch for instance '${instanceId}': orchestration version '${versionCheckResult.orchestrationVersion}' does not match worker version '${this._versioning?.version}'. Abandoning work item.`,
559+
`${versionCheckResult.errorType} for instance '${instanceId}': ${versionCheckResult.errorMessage}. Abandoning work item.`,
544560
);
545561

546562
try {

packages/durabletask-js/src/worker/versioning-options.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,12 @@ export interface VersioningOptions {
4848
*/
4949
version?: string;
5050

51+
/**
52+
* The default version to use when starting new orchestrations without an explicit version.
53+
* This is used by the client when scheduling new orchestrations.
54+
*/
55+
defaultVersion?: string;
56+
5157
/**
5258
* The strategy for matching orchestration versions.
5359
* @default VersionMatchStrategy.None
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
import { TaskHubGrpcClient, TaskHubGrpcClientOptions } from "../src";
5+
6+
describe("TaskHubGrpcClient", () => {
7+
describe("constructor with options", () => {
8+
it("should accept default version option", () => {
9+
const options: TaskHubGrpcClientOptions = {
10+
hostAddress: "localhost:4001",
11+
defaultVersion: "1.0.0",
12+
};
13+
14+
// This should not throw
15+
const client = new TaskHubGrpcClient(options);
16+
17+
expect(client).toBeDefined();
18+
});
19+
20+
it("should work without default version option", () => {
21+
const options: TaskHubGrpcClientOptions = {
22+
hostAddress: "localhost:4001",
23+
};
24+
25+
const client = new TaskHubGrpcClient(options);
26+
27+
expect(client).toBeDefined();
28+
});
29+
30+
it("should accept all standard options", () => {
31+
const options: TaskHubGrpcClientOptions = {
32+
hostAddress: "localhost:4001",
33+
useTLS: false,
34+
defaultVersion: "2.0.0",
35+
};
36+
37+
const client = new TaskHubGrpcClient(options);
38+
39+
expect(client).toBeDefined();
40+
});
41+
});
42+
});

0 commit comments

Comments
 (0)