Skip to content

Commit b1232e5

Browse files
YunchuWangCopilot
andcommitted
refactor: decouple TimerTask cancel via injected handler (match durabletask-python)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent 3090dbb commit b1232e5

3 files changed

Lines changed: 104 additions & 98 deletions

File tree

Lines changed: 33 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,8 @@
11
// Copyright (c) Microsoft Corporation. All rights reserved.
22
// Licensed under the MIT License.
33

4-
import * as pb from "../proto/orchestrator_service_pb";
54
import { CompletableTask } from "./completable-task";
65

7-
/**
8-
* Minimal structural view of the orchestration context bookkeeping that a
9-
* {@link TimerTask} needs in order to cancel a pending timer.
10-
*
11-
* This is intentionally a narrow interface (rather than importing
12-
* `RuntimeOrchestrationContext`) so that `TimerTask` has no circular dependency
13-
* on the worker runtime. The concrete context satisfies it structurally.
14-
*/
15-
export interface TimerBookkeeping {
16-
_pendingActions: Record<number, pb.OrchestratorAction>;
17-
_pendingTasks: Record<number, CompletableTask<any>>;
18-
}
19-
206
/**
217
* A durable timer task returned by `OrchestrationContext.createTimer`.
228
*
@@ -25,6 +11,11 @@ export interface TimerBookkeeping {
2511
* task wins (e.g. via `whenAny`), the losing timeout timer is canceled so the
2612
* orchestration is no longer waiting on it.
2713
*
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+
*
2819
* @example Cancel the loser of a race
2920
* ```typescript
3021
* const timeoutTask = context.createTimer(expirationDate);
@@ -36,45 +27,48 @@ export interface TimerBookkeeping {
3627
* ```
3728
*/
3829
export class TimerTask extends CompletableTask<undefined> {
39-
private readonly _bookkeeping: TimerBookkeeping;
40-
private readonly _timerId: number;
30+
private _cancelHandler?: () => void;
4131
private _isCanceled = false;
4232

4333
/**
44-
* Creates a new TimerTask.
45-
*
46-
* @param bookkeeping - The orchestration context bookkeeping holding the pending
47-
* actions and tasks (the concrete `RuntimeOrchestrationContext` satisfies this).
48-
* @param timerId - The sequence id of the timer's `CreateTimer` action / pending task.
34+
* Whether this timer has been canceled via {@link cancel}.
4935
*/
50-
constructor(bookkeeping: TimerBookkeeping, timerId: number) {
51-
super();
52-
this._bookkeeping = bookkeeping;
53-
this._timerId = timerId;
36+
get isCanceled(): boolean {
37+
return this._isCanceled;
5438
}
5539

5640
/**
57-
* Whether this timer has been canceled via {@link cancel}.
41+
* Registers the handler invoked when this timer is first canceled.
42+
*
43+
* The orchestration context supplies a closure that removes the timer's
44+
* pending `CreateTimer` action and pending-task entry, so this class needs no
45+
* knowledge of the context's internals.
46+
*
47+
* @param handler - Called once, when {@link cancel} first transitions the timer
48+
* to the canceled state.
5849
*/
59-
get isCanceled(): boolean {
60-
return this._isCanceled;
50+
setCancelHandler(handler: () => void): void {
51+
this._cancelHandler = handler;
6152
}
6253

6354
/**
6455
* Cancels this timer so the orchestration stops waiting on it.
6556
*
66-
* Semantics:
67-
* - If the timer's `CreateTimer` action has not yet been dispatched to the
68-
* sidecar (i.e. it is still pending in the current turn), it is removed so
69-
* the timer is never scheduled at all.
70-
* - If the timer was already scheduled on a prior turn, it is removed from the
71-
* pending-task set so the orchestrator no longer waits on it; the backend
72-
* timer is reaped when the orchestration completes, and a late `TimerFired`
73-
* event is ignored because no pending task remains for it.
57+
* The actual bookkeeping is performed by the cancel handler injected via
58+
* {@link setCancelHandler}. The orchestration context's handler:
59+
* - Removes the timer's `CreateTimer` action if it has not yet been dispatched
60+
* to the sidecar (i.e. it is still pending in the current turn), so the timer
61+
* is never scheduled at all.
62+
* - Otherwise drops the timer from the pending-task set so the orchestrator no
63+
* longer waits on it; the backend timer is reaped when the orchestration
64+
* completes, and a late `TimerFired` event is ignored because no pending task
65+
* remains for it.
7466
*
7567
* This is deterministic and replay-safe: it consumes no sequence number and
76-
* only deletes bookkeeping entries by key. Calling `cancel()` after the timer
77-
* has already fired (completed) or after it was already canceled is a no-op.
68+
* only runs the injected handler. Cancel does NOT mark the task complete
69+
* (`isCompleted` stays false). Calling `cancel()` after the timer has already
70+
* fired (completed) or after it was already canceled is a no-op, so the handler
71+
* runs at most once.
7872
*/
7973
cancel(): void {
8074
if (this._isComplete || this._isCanceled) {
@@ -83,11 +77,6 @@ export class TimerTask extends CompletableTask<undefined> {
8377
}
8478

8579
this._isCanceled = true;
86-
87-
// Drop the pending CreateTimer action (if it has not been dispatched yet)
88-
// so it is not scheduled, and stop tracking this timer as outstanding so
89-
// the orchestrator is no longer waiting on it.
90-
delete this._bookkeeping._pendingActions[this._timerId];
91-
delete this._bookkeeping._pendingTasks[this._timerId];
80+
this._cancelHandler?.();
9281
}
9382
}

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -335,7 +335,11 @@ export class RuntimeOrchestrationContext extends OrchestrationContext {
335335
const action = ph.newCreateTimerAction(id, fireAtDate);
336336
this._pendingActions[action.getId()] = action;
337337

338-
const timerTask = new TimerTask(this, id);
338+
const timerTask = new TimerTask();
339+
timerTask.setCancelHandler(() => {
340+
delete this._pendingActions[id];
341+
delete this._pendingTasks[id];
342+
});
339343
this._pendingTasks[id] = timerTask;
340344

341345
return timerTask;
Lines changed: 66 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,16 @@
11
// Copyright (c) Microsoft Corporation. All rights reserved.
22
// Licensed under the MIT License.
33

4-
import * as pb from "../src/proto/orchestrator_service_pb";
54
import { CompletableTask } from "../src/task/completable-task";
6-
import { TimerTask, TimerBookkeeping } from "../src/task/timer-task";
7-
8-
/**
9-
* Minimal fake bookkeeping that mirrors the fields TimerTask.cancel() touches on
10-
* the real RuntimeOrchestrationContext.
11-
*/
12-
function makeBookkeeping(): TimerBookkeeping {
13-
return {
14-
_pendingActions: {} as Record<number, pb.OrchestratorAction>,
15-
_pendingTasks: {} as Record<number, CompletableTask<any>>,
16-
};
17-
}
5+
import { TimerTask } from "../src/task/timer-task";
6+
import { RuntimeOrchestrationContext } from "../src/worker/runtime-orchestration-context";
187

19-
describe("TimerTask", () => {
20-
const TIMER_ID = 1;
8+
// A far-future fire time so timers created in these tests never fire on their own.
9+
const FUTURE_FIRE_AT = new Date(Date.now() + 24 * 60 * 60 * 1000);
2110

11+
describe("TimerTask", () => {
2212
it("should start incomplete, not failed, and not canceled", () => {
23-
const bookkeeping = makeBookkeeping();
24-
const timer = new TimerTask(bookkeeping, TIMER_ID);
13+
const timer = new TimerTask();
2514

2615
expect(timer.isComplete).toBe(false);
2716
expect(timer.isCompleted).toBe(false);
@@ -30,34 +19,54 @@ describe("TimerTask", () => {
3019
});
3120

3221
it("should be a Task/CompletableTask instance (identity preserving, no wrapper)", () => {
33-
const bookkeeping = makeBookkeeping();
34-
const timer = new TimerTask(bookkeeping, TIMER_ID);
22+
const timer = new TimerTask();
3523

3624
// TimerTask must extend CompletableTask so whenAny/whenAll return the real
3725
// instance and `winner === timerTask` identity holds for callers.
3826
expect(timer).toBeInstanceOf(CompletableTask);
3927
});
4028

29+
it("should return the same TimerTask instance that the context stores in _pendingTasks", () => {
30+
// Identity: createTimer must hand back the exact instance it tracks so that
31+
// `winner === timerTask` holds after whenAny.
32+
const ctx = new RuntimeOrchestrationContext("test-instance");
33+
const timer = ctx.createTimer(FUTURE_FIRE_AT);
34+
const timerId = ctx._sequenceNumber;
35+
36+
expect(ctx._pendingTasks[timerId]).toBe(timer);
37+
});
38+
4139
describe("cancel()", () => {
42-
it("should flip isCanceled and drop the pending action and pending task", () => {
43-
const bookkeeping = makeBookkeeping();
44-
const action = new pb.OrchestratorAction();
45-
action.setId(TIMER_ID);
46-
const timer = new TimerTask(bookkeeping, TIMER_ID);
47-
bookkeeping._pendingActions[TIMER_ID] = action;
48-
bookkeeping._pendingTasks[TIMER_ID] = timer;
40+
it("should flip isCanceled and run the injected cancel handler once", () => {
41+
const timer = new TimerTask();
42+
const cancelHandler = jest.fn();
43+
timer.setCancelHandler(cancelHandler);
4944

5045
timer.cancel();
5146

5247
expect(timer.isCanceled).toBe(true);
53-
expect(bookkeeping._pendingActions[TIMER_ID]).toBeUndefined();
54-
expect(bookkeeping._pendingTasks[TIMER_ID]).toBeUndefined();
48+
expect(cancelHandler).toHaveBeenCalledTimes(1);
49+
});
50+
51+
it("should drop the pending CreateTimer action and pending task via the context handler", () => {
52+
// Drive a real context so the injected closure exercises the actual deletion.
53+
const ctx = new RuntimeOrchestrationContext("test-instance");
54+
const timer = ctx.createTimer(FUTURE_FIRE_AT);
55+
const timerId = ctx._sequenceNumber;
56+
57+
expect(ctx._pendingActions[timerId]).toBeDefined();
58+
expect(ctx._pendingTasks[timerId]).toBe(timer);
59+
60+
timer.cancel();
61+
62+
expect(timer.isCanceled).toBe(true);
63+
expect(ctx._pendingActions[timerId]).toBeUndefined();
64+
expect(ctx._pendingTasks[timerId]).toBeUndefined();
5565
});
5666

5767
it("should not mark the timer complete (isCompleted stays false, isFaulted false)", () => {
58-
const bookkeeping = makeBookkeeping();
59-
const timer = new TimerTask(bookkeeping, TIMER_ID);
60-
bookkeeping._pendingTasks[TIMER_ID] = timer;
68+
const timer = new TimerTask();
69+
timer.setCancelHandler(jest.fn());
6170

6271
timer.cancel();
6372

@@ -66,45 +75,49 @@ describe("TimerTask", () => {
6675
expect(timer.isFaulted).toBe(false);
6776
});
6877

69-
it("should be idempotent when called multiple times", () => {
70-
const bookkeeping = makeBookkeeping();
71-
const timer = new TimerTask(bookkeeping, TIMER_ID);
72-
bookkeeping._pendingTasks[TIMER_ID] = timer;
78+
it("should be idempotent when called multiple times (handler runs only once)", () => {
79+
const timer = new TimerTask();
80+
const cancelHandler = jest.fn();
81+
timer.setCancelHandler(cancelHandler);
7382

7483
timer.cancel();
7584
expect(() => timer.cancel()).not.toThrow();
85+
7686
expect(timer.isCanceled).toBe(true);
87+
expect(cancelHandler).toHaveBeenCalledTimes(1);
7788
});
7889

7990
it("should not remove an unrelated pending action/task with a different id", () => {
80-
const bookkeeping = makeBookkeeping();
81-
const otherAction = new pb.OrchestratorAction();
82-
otherAction.setId(2);
83-
const otherTask = new CompletableTask<number>();
84-
bookkeeping._pendingActions[2] = otherAction;
85-
bookkeeping._pendingTasks[2] = otherTask;
86-
87-
const timer = new TimerTask(bookkeeping, TIMER_ID);
88-
bookkeeping._pendingTasks[TIMER_ID] = timer;
89-
90-
timer.cancel();
91-
92-
// The sibling entries at id 2 must remain untouched.
93-
expect(bookkeeping._pendingActions[2]).toBe(otherAction);
94-
expect(bookkeeping._pendingTasks[2]).toBe(otherTask);
91+
// Two sibling timers on one real context; canceling one must not touch the other.
92+
const ctx = new RuntimeOrchestrationContext("test-instance");
93+
const firstTimer = ctx.createTimer(FUTURE_FIRE_AT);
94+
const firstId = ctx._sequenceNumber;
95+
const secondTimer = ctx.createTimer(FUTURE_FIRE_AT);
96+
const secondId = ctx._sequenceNumber;
97+
98+
firstTimer.cancel();
99+
100+
// The sibling entries at the other id must remain untouched.
101+
expect(ctx._pendingActions[secondId]).toBeDefined();
102+
expect(ctx._pendingTasks[secondId]).toBe(secondTimer);
103+
// The canceled timer's entries are gone.
104+
expect(ctx._pendingActions[firstId]).toBeUndefined();
105+
expect(ctx._pendingTasks[firstId]).toBeUndefined();
95106
});
96107

97108
it("should be a no-op after the timer has already fired (completed)", () => {
98-
const bookkeeping = makeBookkeeping();
99-
const timer = new TimerTask(bookkeeping, TIMER_ID);
109+
const timer = new TimerTask();
110+
const cancelHandler = jest.fn();
111+
timer.setCancelHandler(cancelHandler);
100112

101113
// Simulate the timer firing (handleTimerFired calls complete(undefined)).
102114
timer.complete(undefined);
103115

104116
expect(() => timer.cancel()).not.toThrow();
105-
// Canceling a fired timer must not flip isCanceled.
117+
// Canceling a fired timer must not flip isCanceled or run the handler.
106118
expect(timer.isCanceled).toBe(false);
107119
expect(timer.isCompleted).toBe(true);
120+
expect(cancelHandler).not.toHaveBeenCalled();
108121
});
109122
});
110123
});

0 commit comments

Comments
 (0)