Skip to content

Commit 215f990

Browse files
authored
Fix #292: align core Task/TimerTask public shape (result/isCompleted/isFaulted, cancellable TimerTask) (#293)
1 parent 144bdab commit 215f990

8 files changed

Lines changed: 457 additions & 4 deletions

File tree

packages/durabletask-js/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ export { FailureDetails, TaskFailureDetails } from "./task/failure-details";
7676
// Task utilities
7777
export { getName, whenAll, whenAny } from "./task";
7878
export { Task } from "./task/task";
79+
export { TimerTask } from "./task/timer-task";
7980

8081
// Retry policies and task options
8182
export {

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

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { Logger } from "../../types/logger.type";
88
import { ReplaySafeLogger } from "../../types/replay-safe-logger";
99
import { TaskOptions, SubOrchestrationOptions } from "../options";
1010
import { Task } from "../task";
11+
import { TimerTask } from "../timer-task";
1112
import { OrchestrationEntityFeature } from "../../entities/orchestration-entity-feature";
1213
import { compareVersions } from "../../utils/versioning.util";
1314

@@ -107,10 +108,17 @@ export abstract class OrchestrationContext {
107108
/**
108109
* Create a timer task that will fire at a specified time.
109110
*
111+
* @remarks
112+
* This abstract method is implemented only by the SDK's own orchestration
113+
* context (`RuntimeOrchestrationContext`); orchestrator code consumes a context
114+
* instance and does not subclass it. The return type is the concrete
115+
* {@link TimerTask} (which is a `Task`) so callers can cancel the timer — this
116+
* narrowing is safe for all callers.
117+
*
110118
* @param {Date | number} fireAt The time at which the timer should fire.
111-
* @returns {Task} A Durable Timer task that schedules the timer to wake up the orchestrator
119+
* @returns {TimerTask} A cancellable Durable Timer task that schedules the timer to wake up the orchestrator
112120
*/
113-
abstract createTimer(fireAt: Date | number): Task<any>;
121+
abstract createTimer(fireAt: Date | number): TimerTask;
114122

115123
/**
116124
* Schedule an activity for execution.

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

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,32 @@ export class Task<T> {
3232
return this._exception != undefined;
3333
}
3434

35+
/**
36+
* Alias of {@link isComplete} that matches the v3 Durable Functions `Task` shape.
37+
* Note that completion is not equivalent to success.
38+
*/
39+
get isCompleted(): boolean {
40+
return this._isComplete;
41+
}
42+
43+
/**
44+
* Alias of {@link isFailed} that matches the v3 Durable Functions `Task` shape.
45+
*/
46+
get isFaulted(): boolean {
47+
return this.isFailed;
48+
}
49+
50+
/**
51+
* The result of the task if it has completed successfully, otherwise `undefined`.
52+
*
53+
* Unlike {@link getResult}, this getter never throws: it returns `undefined`
54+
* while the task is still pending and for a failed task. This matches the v3
55+
* Durable Functions `Task.result` shape.
56+
*/
57+
get result(): T | undefined {
58+
return this._isComplete && !this._exception ? this._result : undefined;
59+
}
60+
3561
/**
3662
* Get the result of the task
3763
*/
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
import { CompletableTask } from "./completable-task";
5+
6+
/**
7+
* A durable timer task returned by `OrchestrationContext.createTimer`.
8+
*
9+
* In addition to the normal {@link Task} surface, a `TimerTask` can be
10+
* canceled. Canceling is the classic "timeout vs. work" pattern: when a racing
11+
* task wins (e.g. via `whenAny`), the losing timeout timer is canceled so the
12+
* orchestration is no longer waiting on it.
13+
*
14+
* `TimerTask` is decoupled from the orchestration context's internal
15+
* bookkeeping: the context injects a cancel handler via {@link setCancelHandler}
16+
* and this class knows nothing about pending actions or tasks. This mirrors the
17+
* `CancellableTask.set_cancel_handler` pattern in the Python SDK.
18+
*
19+
* @example Cancel the loser of a race
20+
* ```typescript
21+
* import { whenAny } from "@microsoft/durabletask-js";
22+
*
23+
* const timeoutTask = context.createTimer(expirationDate);
24+
* const workTask = context.callActivity("DoWork");
25+
* const winner = yield whenAny([timeoutTask, workTask]);
26+
* if (winner === workTask && !timeoutTask.isCompleted) {
27+
* timeoutTask.cancel();
28+
* }
29+
* ```
30+
*/
31+
export class TimerTask extends CompletableTask<undefined> {
32+
private _cancelHandler?: () => void;
33+
private _isCanceled = false;
34+
35+
/**
36+
* Whether this timer has been canceled via {@link cancel}.
37+
*/
38+
get isCanceled(): boolean {
39+
return this._isCanceled;
40+
}
41+
42+
/**
43+
* Registers the handler invoked when this timer is first canceled.
44+
*
45+
* The orchestration context supplies a closure that removes the timer's
46+
* pending `CreateTimer` action and pending-task entry, so this class needs no
47+
* knowledge of the context's internals.
48+
*
49+
* @internal Invoked by the orchestration context when the timer is created.
50+
* Not part of the public `TimerTask` surface; orchestrator code must not call it.
51+
* @param handler - Called once, when {@link cancel} first transitions the timer
52+
* to the canceled state.
53+
*/
54+
setCancelHandler(handler: () => void): void {
55+
this._cancelHandler = handler;
56+
}
57+
58+
/**
59+
* Cancels this timer so the orchestration stops waiting on it.
60+
*
61+
* The actual bookkeeping is performed by the cancel handler injected via
62+
* {@link setCancelHandler}. The orchestration context's handler:
63+
* - Removes the timer's `CreateTimer` action if it has not yet been dispatched
64+
* to the sidecar (i.e. it is still pending in the current turn), so the timer
65+
* is never scheduled at all.
66+
* - Otherwise drops the timer from the pending-task set so the orchestrator no
67+
* longer waits on it; the backend timer is reaped when the orchestration
68+
* completes, and a late `TimerFired` event is ignored because no pending task
69+
* remains for it.
70+
*
71+
* This is deterministic and replay-safe: it consumes no sequence number and
72+
* only runs the injected handler. Cancel does NOT mark the task complete
73+
* (`isCompleted` stays false). Calling `cancel()` after the timer has already
74+
* fired (completed) or after it was already canceled is a no-op, so the handler
75+
* runs at most once.
76+
*/
77+
cancel(): void {
78+
if (this._isComplete || this._isCanceled) {
79+
// Already fired or already canceled — nothing to do.
80+
return;
81+
}
82+
83+
this._isCanceled = true;
84+
this._cancelHandler?.();
85+
}
86+
}

packages/durabletask-js/src/worker/runtime-orchestration-context.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { RetryTaskBase, RetryTaskType } from "../task/retry-task-base";
1212
import { RetryableTask } from "../task/retryable-task";
1313
import { RetryHandlerTask } from "../task/retry-handler-task";
1414
import { RetryTimerTask } from "../task/retry-timer-task";
15+
import { TimerTask } from "../task/timer-task";
1516
import { TaskOptions, SubOrchestrationOptions, isRetryPolicy, isRetryHandler } from "../task/options";
1617
import { toAsyncRetryHandler } from "../task/retry/retry-handler";
1718
import { TActivity } from "../types/activity.type";
@@ -306,7 +307,7 @@ export class RuntimeOrchestrationContext extends OrchestrationContext {
306307
* @param fireAt Date The date when the timer should fire
307308
* @returns
308309
*/
309-
createTimer(fireAt: number | Date): Task<any> {
310+
createTimer(fireAt: number | Date): TimerTask {
310311
const id = this.nextSequenceNumber();
311312

312313
let fireAtDate: Date;
@@ -334,7 +335,11 @@ export class RuntimeOrchestrationContext extends OrchestrationContext {
334335
const action = ph.newCreateTimerAction(id, fireAtDate);
335336
this._pendingActions[action.getId()] = action;
336337

337-
const timerTask = new CompletableTask();
338+
const timerTask = new TimerTask();
339+
timerTask.setCancelHandler(() => {
340+
delete this._pendingActions[id];
341+
delete this._pendingTasks[id];
342+
});
338343
this._pendingTasks[id] = timerTask;
339344

340345
return timerTask;

packages/durabletask-js/test/orchestration_executor.spec.ts

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2369,6 +2369,147 @@ describe("EventSent Handler", () => {
23692369
expect(failureDetails?.getErrortype()).toEqual("NonDeterminismError");
23702370
expect(failureDetails?.getErrormessage()).toMatch(/sendEvent/);
23712371
});
2372+
2373+
// Regression coverage for issue #292: the classic "activity vs. timeout" race
2374+
// where the winner cancels the loser timer (cancellable TimerTask).
2375+
it("should complete the timer-vs-activity race when the activity wins and the loser timer is canceled", async () => {
2376+
const hello = (_: any, name: string) => `Hello ${name}!`;
2377+
2378+
const orchestrator: TOrchestrator = async function* (ctx: OrchestrationContext): any {
2379+
const timeoutTask = ctx.createTimer(new Date(ctx.currentUtcDateTime.getTime() + 24 * 60 * 60 * 1000));
2380+
const activityTask = ctx.callActivity(hello, "Tokyo");
2381+
2382+
const winner = yield whenAny([timeoutTask, activityTask]);
2383+
2384+
// Identity must be preserved: whenAny returns the exact activity task instance.
2385+
if (winner === activityTask) {
2386+
if (!timeoutTask.isCompleted) {
2387+
timeoutTask.cancel();
2388+
}
2389+
return activityTask.getResult();
2390+
}
2391+
return "timed out";
2392+
};
2393+
2394+
const registry = new Registry();
2395+
const orchestratorName = registry.addOrchestrator(orchestrator);
2396+
const activityName = registry.addActivity(hello);
2397+
2398+
// Turn 1: schedules the timer (action 1) and the activity (action 2), then yields on whenAny.
2399+
const startTime = new Date(2020, 0, 1, 12, 0, 0);
2400+
let executor = new OrchestrationExecutor(registry, testLogger);
2401+
let result = await executor.execute(TEST_INSTANCE_ID, [], [
2402+
newOrchestratorStartedEvent(startTime),
2403+
newExecutionStartedEvent(orchestratorName, TEST_INSTANCE_ID),
2404+
]);
2405+
expect(result.actions.length).toEqual(2);
2406+
expect(result.actions[0].hasCreatetimer()).toBeTruthy();
2407+
expect(result.actions[1].hasScheduletask()).toBeTruthy();
2408+
2409+
// Turn 2: the activity completes first; the timer has NOT fired.
2410+
const timerFireAt = new Date(startTime.getTime() + 24 * 60 * 60 * 1000);
2411+
const oldEvents = [
2412+
newOrchestratorStartedEvent(startTime),
2413+
newExecutionStartedEvent(orchestratorName, TEST_INSTANCE_ID),
2414+
newTimerCreatedEvent(1, timerFireAt),
2415+
newTaskScheduledEvent(2, activityName, JSON.stringify("Tokyo")),
2416+
];
2417+
const encodedOutput = JSON.stringify(hello(null, "Tokyo"));
2418+
executor = new OrchestrationExecutor(registry, testLogger);
2419+
result = await executor.execute(TEST_INSTANCE_ID, oldEvents, [newTaskCompletedEvent(2, encodedOutput)]);
2420+
2421+
// A single CompleteOrchestration action, carrying the activity's result, and
2422+
// no leftover CreateTimer action (the canceled timer must not be rescheduled).
2423+
const completeAction = getAndValidateSingleCompleteOrchestrationAction(result);
2424+
expect(completeAction?.getOrchestrationstatus()).toEqual(pb.OrchestrationStatus.ORCHESTRATION_STATUS_COMPLETED);
2425+
expect(completeAction?.getResult()?.getValue()).toEqual(encodedOutput);
2426+
expect(result.actions.some((a) => a.hasCreatetimer())).toBe(false);
2427+
});
2428+
2429+
it("should complete normally when the timer wins the race (no regression to timer firing)", async () => {
2430+
const hello = (_: any, name: string) => `Hello ${name}!`;
2431+
2432+
const orchestrator: TOrchestrator = async function* (ctx: OrchestrationContext): any {
2433+
const timeoutTask = ctx.createTimer(new Date(ctx.currentUtcDateTime.getTime() + 24 * 60 * 60 * 1000));
2434+
const activityTask = ctx.callActivity(hello, "Tokyo");
2435+
2436+
const winner = yield whenAny([timeoutTask, activityTask]);
2437+
2438+
if (winner === timeoutTask) {
2439+
return "timed out";
2440+
}
2441+
return activityTask.getResult();
2442+
};
2443+
2444+
const registry = new Registry();
2445+
const orchestratorName = registry.addOrchestrator(orchestrator);
2446+
const activityName = registry.addActivity(hello);
2447+
2448+
const startTime = new Date(2020, 0, 1, 12, 0, 0);
2449+
const timerFireAt = new Date(startTime.getTime() + 24 * 60 * 60 * 1000);
2450+
const oldEvents = [
2451+
newOrchestratorStartedEvent(startTime),
2452+
newExecutionStartedEvent(orchestratorName, TEST_INSTANCE_ID),
2453+
newTimerCreatedEvent(1, timerFireAt),
2454+
newTaskScheduledEvent(2, activityName, JSON.stringify("Tokyo")),
2455+
];
2456+
2457+
// The timer fires first: the orchestration should still complete via the timeout branch.
2458+
const executor = new OrchestrationExecutor(registry, testLogger);
2459+
const result = await executor.execute(TEST_INSTANCE_ID, oldEvents, [newTimerFiredEvent(1, timerFireAt)]);
2460+
2461+
const completeAction = getAndValidateSingleCompleteOrchestrationAction(result);
2462+
expect(completeAction?.getOrchestrationstatus()).toEqual(pb.OrchestrationStatus.ORCHESTRATION_STATUS_COMPLETED);
2463+
expect(completeAction?.getResult()?.getValue()).toEqual(JSON.stringify("timed out"));
2464+
});
2465+
2466+
it("should ignore a fired event for a canceled timer (replay-safe) and keep running", async () => {
2467+
const hello = (_: any, name: string) => `Hello ${name}!`;
2468+
2469+
// The orchestration cancels the timer, then continues with more work. A late
2470+
// TimerFired event for the canceled timer must be ignored (no crash, no
2471+
// premature completion) rather than resuming the orchestrator.
2472+
const orchestrator: TOrchestrator = async function* (ctx: OrchestrationContext): any {
2473+
const timeoutTask = ctx.createTimer(new Date(ctx.currentUtcDateTime.getTime() + 24 * 60 * 60 * 1000));
2474+
const activityTask = ctx.callActivity(hello, "Tokyo");
2475+
2476+
const winner = yield whenAny([timeoutTask, activityTask]);
2477+
if (winner === activityTask && !timeoutTask.isCompleted) {
2478+
timeoutTask.cancel();
2479+
}
2480+
2481+
// Continue after canceling the timer.
2482+
const second = yield ctx.callActivity(hello, "Seattle");
2483+
return second;
2484+
};
2485+
2486+
const registry = new Registry();
2487+
const orchestratorName = registry.addOrchestrator(orchestrator);
2488+
const activityName = registry.addActivity(hello);
2489+
2490+
const startTime = new Date(2020, 0, 1, 12, 0, 0);
2491+
const timerFireAt = new Date(startTime.getTime() + 24 * 60 * 60 * 1000);
2492+
const oldEvents = [
2493+
newOrchestratorStartedEvent(startTime),
2494+
newExecutionStartedEvent(orchestratorName, TEST_INSTANCE_ID),
2495+
newTimerCreatedEvent(1, timerFireAt),
2496+
newTaskScheduledEvent(2, activityName, JSON.stringify("Tokyo")),
2497+
];
2498+
2499+
// The first activity completes AND the (canceled) timer fires in the same batch.
2500+
const encodedOutput = JSON.stringify(hello(null, "Tokyo"));
2501+
const executor = new OrchestrationExecutor(registry, testLogger);
2502+
const result = await executor.execute(TEST_INSTANCE_ID, oldEvents, [
2503+
newTaskCompletedEvent(2, encodedOutput),
2504+
newTimerFiredEvent(1, timerFireAt),
2505+
]);
2506+
2507+
// The fired canceled timer is ignored; the orchestration schedules the second
2508+
// activity and is NOT complete.
2509+
expect(result.actions.length).toEqual(1);
2510+
expect(result.actions[0].hasScheduletask()).toBeTruthy();
2511+
expect(result.actions.some((a) => a.hasCompleteorchestration())).toBe(false);
2512+
});
23722513
});
23732514

23742515
describe("Entity call on classic (Azure Storage) backend — EVENTSENT/EVENTRAISED", () => {

0 commit comments

Comments
 (0)