Skip to content

Fix #292: align core Task/TimerTask public shape (result/isCompleted/isFaulted, cancellable TimerTask)#293

Open
YunchuWang wants to merge 1 commit into
mainfrom
yunchuwang-fix-292-task-timertask-v3-shape
Open

Fix #292: align core Task/TimerTask public shape (result/isCompleted/isFaulted, cancellable TimerTask)#293
YunchuWang wants to merge 1 commit into
mainfrom
yunchuwang-fix-292-task-timertask-v3-shape

Conversation

@YunchuWang

Copy link
Copy Markdown
Member

Summary

Fixes #292.

The v4 core Task / TimerTask public shape dropped members that the Azure Functions
Durable 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 can
consume real core types — no wrappers that would break === identity in the classic
Task.any([...]) race.

Scope note: the azure-functions-durable compat package is not on main (it lives in
#282), so this PR makes the alignment in core; #282 will consume it.

B1 — v3-aligned aliases on base Task<T> (additive, non-breaking)

Added member Behavior
get result(): T | undefined Value when completed, otherwise undefined. Never throws (distinct from getResult(), which throws when incomplete). Failed tasks return undefined.
get isCompleted(): boolean Alias of isComplete.
get isFaulted(): boolean Alias of isFailed.

getResult() / isComplete / isFailed are unchanged.

B2 — cancellable TimerTask primitive

  • New TimerTask extends CompletableTask<undefined> exposing cancel(): void and
    isCanceled: boolean.
  • createTimer now returns the real TimerTask instance that is stored in the context's
    _pendingTasks, so whenAny / whenAll return that exact object and
    winner === timerTask / winner === activityTask holds. No wrapper / proxy — identity
    is preserved.
  • The public abstract OrchestrationContext.createTimer return type is widened
    Task<any>TimerTask (additive; TimerTask is a Task, so existing
    yield ctx.createTimer(...) callers are unaffected).
  • TimerTask is exported from the package entrypoint for the compat layer.

How cancel() lets the orchestrator complete (deterministic & replay-safe)

  • Timer not yet dispatched (same turn as creation): the CreateTimer action is still in
    _pendingActions; cancel() deletes it, so the timer is never scheduled.
  • Timer already dispatched (prior turn): the backend timer is orphaned. cancel() removes
    the pending task so the orchestrator stops waiting; the generator returns, the orchestration
    completes, and the backend reaps outstanding timers on completion. A late TimerFired finds
    no pending task and hits the existing graceful "unexpected event → ignore" path in
    handleTimerFired — no code change there.
  • Determinism: cancel() runs from deterministic orchestrator code, consumes no
    sequence number, and only deletes bookkeeping entries by key, so other action IDs are
    unaffected. No NonDeterminismError risk. (The DTFx wire protocol has no CancelTimer
    action; cancellation is an orchestrator-side concept, consistent with the .NET/Python SDKs.)
  • To avoid a circular import with the worker runtime, TimerTask takes a minimal structural
    TimerBookkeeping interface ({ _pendingActions; _pendingTasks }) that the concrete context
    satisfies, plus the numeric timer id.

Decisions needing maintainer sign-off

  1. Home of the v3 aliases. result / isCompleted / isFaulted are added to the core
    public Task per the issue ("trivial, safe aliases"). Confirm core is the desired home
    (vs. compat-only).
  2. cancel() on an already-fired/completed timer. DF v3 DFTimerTask.cancel() throws
    ("Cannot cancel a completed task."). This PR makes the core primitive an idempotent
    no-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.
  3. Cancellation model. cancel() does not mark the timer complete (isCompleted stays
    false, isCanceled becomes true) and relies on orchestration completion to reap an
    already-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 / whenAny unchanged; added an explicit assertion that whenAny returns the
    same child instance (identity) — both at task level and through the executor
    timer/activity race.
  • Normal timer completion path unchanged (timer-wins race still completes; existing timer tests
    pass).
  • cancel() before fire drops the pending timer and flips isCanceled; cancel() after fire
    and double-cancel() are safe no-ops; canceling one timer does not touch sibling ids.
  • result never throws (undefined when incomplete/failed, value when completed);
    isCompleted / isFaulted reflect state.

Tests

  • test/task.spec.ts: result / isCompleted / isFaulted across incomplete / completed /
    failed.
  • test/timer-task.spec.ts (new): cancel() removes action + task and flips isCanceled;
    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 — activity
    wins → winner === activityTask, loser timer canceled, single CompleteOrchestration, no
    leftover CreateTimer; timer-wins path still completes; replay-safe ignore of a fired
    canceled timer (orchestration keeps running).

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>
Copilot AI review requested due to automatic review settings July 9, 2026 17:24

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 Task aliases: result, isCompleted, and isFaulted.
  • Introduced a cancellable TimerTask primitive and updated createTimer() to return it (preserving identity across whenAny/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;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

azure-functions-durable (v4 compat): Task/TimerTask missing v3 members (result/isCompleted/isFaulted, cancel/isCanceled)

2 participants