Fix #292: align core Task/TimerTask public shape (result/isCompleted/isFaulted, cancellable TimerTask)#293
Open
YunchuWang wants to merge 1 commit into
Open
Conversation
Add v3 Durable Functions-aligned members to the core task surface so the azure-functions-durable compat layer (PR #282) can consume real core types without wrappers that would break `===` identity. B1 (additive, non-breaking) on base Task<T>: - result: getter returning the value when completed, else undefined (never throws; distinct from getResult() which throws when incomplete) - isCompleted: alias of isComplete - isFaulted: alias of isFailed B2 cancellable timer primitive: - New TimerTask extends CompletableTask<undefined> exposing cancel() and isCanceled - createTimer now returns the real TimerTask instance stored in _pendingTasks, so whenAny/whenAll preserve identity (winner === timerTask / activityTask) - cancel() removes the pending CreateTimer action and pending task by key; it is deterministic and replay-safe (consumes no sequence number). Idempotent no-op after fire/cancel. A late TimerFired for a canceled timer hits the existing graceful ignore path in handleTimerFired. - Export TimerTask from the package entrypoint. Tests: result/isCompleted/isFaulted across pending/completed/failed; TimerTask cancel drops action+task, idempotency, no-op after fire, sibling-id isolation, identity; executor race coverage (activity wins -> loser canceled, single CompleteOrchestration, no leftover CreateTimer; timer wins; replay-safe ignore of a fired canceled timer). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Contributor
There was a problem hiding this comment.
Pull request overview
Aligns the core DurableTask JS SDK’s Task and timer primitives with the Azure Functions Durable JS v3 programming model so the v4 compat layer can expose real core task objects (preserving === identity) while restoring expected members (result/isCompleted/isFaulted) and enabling the classic timeout-cancellation pattern.
Changes:
- Added v3-compatible
Taskaliases:result,isCompleted, andisFaulted. - Introduced a cancellable
TimerTaskprimitive and updatedcreateTimer()to return it (preserving identity acrosswhenAny/whenAll). - Added unit/regression coverage for timer cancellation + identity and for timer-vs-activity race behavior in the executor.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/durabletask-js/src/task/task.ts | Adds v3-compatible aliases on the base Task type (result/isCompleted/isFaulted). |
| packages/durabletask-js/src/task/timer-task.ts | Introduces the new cancellable TimerTask primitive and its bookkeeping contract. |
| packages/durabletask-js/src/task/context/orchestration-context.ts | Widens the public createTimer() return type to TimerTask for orchestrator code. |
| packages/durabletask-js/src/worker/runtime-orchestration-context.ts | Updates concrete createTimer() to construct and return a real TimerTask. |
| packages/durabletask-js/src/index.ts | Exports TimerTask from the package entrypoint for compat consumption. |
| packages/durabletask-js/test/task.spec.ts | Adds tests for Task.result/isCompleted/isFaulted behavior across states. |
| packages/durabletask-js/test/timer-task.spec.ts | New tests for TimerTask.cancel() semantics and identity expectations. |
| packages/durabletask-js/test/orchestration_executor.spec.ts | New executor regression coverage for the activity-vs-timeout race + canceled timer behavior. |
Comment on lines
+57
to
+59
| get result(): T | undefined { | ||
| return this._isComplete ? this._result : undefined; | ||
| } |
Comment on lines
+28
to
+36
| * @example Cancel the loser of a race | ||
| * ```typescript | ||
| * const timeoutTask = context.createTimer(expirationDate); | ||
| * const workTask = context.callActivity("DoWork"); | ||
| * const winner = yield context.whenAny([timeoutTask, workTask]); | ||
| * if (winner === workTask && !timeoutTask.isCompleted) { | ||
| * timeoutTask.cancel(); | ||
| * } | ||
| * ``` |
Comment on lines
108
to
+114
| /** | ||
| * Create a timer task that will fire at a specified time. | ||
| * | ||
| * @param {Date | number} fireAt The time at which the timer should fire. | ||
| * @returns {Task} A Durable Timer task that schedules the timer to wake up the orchestrator | ||
| * @returns {TimerTask} A cancellable Durable Timer task that schedules the timer to wake up the orchestrator | ||
| */ | ||
| abstract createTimer(fireAt: Date | number): Task<any>; | ||
| abstract createTimer(fireAt: Date | number): TimerTask; |
Comment on lines
+15
to
+54
| export interface TimerBookkeeping { | ||
| _pendingActions: Record<number, pb.OrchestratorAction>; | ||
| _pendingTasks: Record<number, CompletableTask<any>>; | ||
| } | ||
|
|
||
| /** | ||
| * A durable timer task returned by `OrchestrationContext.createTimer`. | ||
| * | ||
| * In addition to the normal {@link Task} surface, a `TimerTask` can be | ||
| * canceled. Canceling is the classic "timeout vs. work" pattern: when a racing | ||
| * task wins (e.g. via `whenAny`), the losing timeout timer is canceled so the | ||
| * orchestration is no longer waiting on it. | ||
| * | ||
| * @example Cancel the loser of a race | ||
| * ```typescript | ||
| * const timeoutTask = context.createTimer(expirationDate); | ||
| * const workTask = context.callActivity("DoWork"); | ||
| * const winner = yield context.whenAny([timeoutTask, workTask]); | ||
| * if (winner === workTask && !timeoutTask.isCompleted) { | ||
| * timeoutTask.cancel(); | ||
| * } | ||
| * ``` | ||
| */ | ||
| export class TimerTask extends CompletableTask<undefined> { | ||
| private readonly _bookkeeping: TimerBookkeeping; | ||
| private readonly _timerId: number; | ||
| private _isCanceled = false; | ||
|
|
||
| /** | ||
| * Creates a new TimerTask. | ||
| * | ||
| * @param bookkeeping - The orchestration context bookkeeping holding the pending | ||
| * actions and tasks (the concrete `RuntimeOrchestrationContext` satisfies this). | ||
| * @param timerId - The sequence id of the timer's `CreateTimer` action / pending task. | ||
| */ | ||
| constructor(bookkeeping: TimerBookkeeping, timerId: number) { | ||
| super(); | ||
| this._bookkeeping = bookkeeping; | ||
| this._timerId = timerId; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Fixes #292.
The v4 core
Task/TimerTaskpublic shape dropped members that the Azure FunctionsDurable JS v3 programming model exposes, which blocks the compat layer (PR #282, not on
main). This aligns the core package (packages/durabletask-js/) so the compat layer canconsume real core types — no wrappers that would break
===identity in the classicTask.any([...])race.B1 — v3-aligned aliases on base
Task<T>(additive, non-breaking)get result(): T | undefinedundefined. Never throws (distinct fromgetResult(), which throws when incomplete). Failed tasks returnundefined.get isCompleted(): booleanisComplete.get isFaulted(): booleanisFailed.getResult()/isComplete/isFailedare unchanged.B2 — cancellable
TimerTaskprimitiveTimerTask extends CompletableTask<undefined>exposingcancel(): voidandisCanceled: boolean.createTimernow returns the realTimerTaskinstance that is stored in the context's_pendingTasks, sowhenAny/whenAllreturn that exact object andwinner === timerTask/winner === activityTaskholds. No wrapper / proxy — identityis preserved.
OrchestrationContext.createTimerreturn type is widenedTask<any>→TimerTask(additive;TimerTaskis aTask, so existingyield ctx.createTimer(...)callers are unaffected).TimerTaskis exported from the package entrypoint for the compat layer.How
cancel()lets the orchestrator complete (deterministic & replay-safe)CreateTimeraction is still in_pendingActions;cancel()deletes it, so the timer is never scheduled.cancel()removesthe pending task so the orchestrator stops waiting; the generator returns, the orchestration
completes, and the backend reaps outstanding timers on completion. A late
TimerFiredfindsno pending task and hits the existing graceful "unexpected event → ignore" path in
handleTimerFired— no code change there.cancel()runs from deterministic orchestrator code, consumes nosequence number, and only deletes bookkeeping entries by key, so other action IDs are
unaffected. No
NonDeterminismErrorrisk. (The DTFx wire protocol has noCancelTimeraction; cancellation is an orchestrator-side concept, consistent with the .NET/Python SDKs.)
TimerTasktakes a minimal structuralTimerBookkeepinginterface ({ _pendingActions; _pendingTasks }) that the concrete contextsatisfies, plus the numeric timer id.
Decisions needing maintainer sign-off
result/isCompleted/isFaultedare added to the corepublic
Taskper the issue ("trivial, safe aliases"). Confirm core is the desired home(vs. compat-only).
cancel()on an already-fired/completed timer. DF v3DFTimerTask.cancel()throws(
"Cannot cancel a completed task."). This PR makes the core primitive an idempotentno-op (safer, and matches the requested regression test). The compat layer can re-add the
throw for exact v3 parity. Confirm no-op is acceptable for the core primitive.
cancel()does not mark the timer complete (isCompletedstaysfalse,isCanceledbecomestrue) and relies on orchestration completion to reap analready-scheduled backend timer (no wire-level cancel). Confirm this semantic.
Regression analysis (no regressions)
npm run build:core✅ ·npm run lint✅ ·npm run test:unit -w @microsoft/durabletask-js✅ (61 suites, 1086 tests).
whenAll/whenAnyunchanged; added an explicit assertion thatwhenAnyreturns thesame child instance (identity) — both at task level and through the executor
timer/activity race.
pass).
cancel()before fire drops the pending timer and flipsisCanceled;cancel()after fireand double-
cancel()are safe no-ops; canceling one timer does not touch sibling ids.resultnever throws (undefined when incomplete/failed, value when completed);isCompleted/isFaultedreflect state.Tests
test/task.spec.ts:result/isCompleted/isFaultedacross incomplete / completed /failed.
test/timer-task.spec.ts(new):cancel()removes action + task and flipsisCanceled;idempotent; no-op after complete; sibling-id isolation; created object identity == stored
pending task;
instanceof CompletableTask.test/orchestration_executor.spec.ts(new cases): classic timer-vs-activity race — activitywins →
winner === activityTask, loser timer canceled, singleCompleteOrchestration, noleftover
CreateTimer; timer-wins path still completes; replay-safe ignore of a firedcanceled timer (orchestration keeps running).