Skip to content

fix: cancel pending timers on continue-as-new in InMemoryOrchestrationBackend - #328

Open
YunchuWang wants to merge 1 commit into
mainfrom
copilot-finds/bug/fix-continue-as-new-timer-leak
Open

fix: cancel pending timers on continue-as-new in InMemoryOrchestrationBackend#328
YunchuWang wants to merge 1 commit into
mainfrom
copilot-finds/bug/fix-continue-as-new-timer-leak

Conversation

@YunchuWang

@YunchuWang YunchuWang commented Jul 23, 2026

Copy link
Copy Markdown
Member

Fixes #207.

The bug

InMemoryOrchestrationBackend's continue-as-new handler resets instance history but never
cancels the timers still pending from the previous iteration.

Timer IDs are per-execution sequence numbers that restart at 1 after continue-as-new. So a
timer abandoned by iteration 1 keeps its setTimeout alive and later fires a TimerFired
event whose ID collides with iteration 2's first task — completing it long before it is due.

The fix

Call this.cancelInstanceTimers(instance.instanceId) before resetting instance state. The
helper already exists and is used elsewhere in the class; continue-as-new was simply missing it.

Validation — verified against current main

Elapsed Result
Without the fix 58 ms FAIL — expect(elapsedMs).toBeGreaterThan(1000) got 58
With the fix 1562 ms pass

Iteration 2 asks for a 1.5 s timer. Without the fix it finishes in 58 ms, because iteration 1's
abandoned 50 ms timer fires and satisfies it. That 27x gap is the bug.

Full validation: build 0, lint 0, 1177/1177 tests across 67 suites.

Note for reviewers — the test was rewritten, and why

The original test on this branch could not fail. It used:

ctx.createTimer(60);       // iteration 1 stale timer — 60 SECONDS
...
yield ctx.createTimer(0.05); // iteration 2 real timer — 50ms

The stale timer was longer than the entire test, so it could never fire in time to cause the
collision, and the test passed identically with and without the fix. The durations needed to be
inverted.

This version uses a 50 ms stale timer against a 1.5 s real timer and asserts on elapsed
wall time, so a leak shows up as an early completion. The 1000 ms threshold leaves a wide margin
for CI jitter.

Note on the rebase

This branch was rebased onto current main. The rebase could not be done mechanically: the test
rewrite lived in a merge commit, which git rebase drops, so replaying the original commit alone
would have silently restored the broken 60 s test. The change was re-applied onto current main
and re-verified from scratch with the numbers above.

Copilot AI review requested due to automatic review settings July 23, 2026 16:39

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

This PR fixes a correctness issue in the in-memory testing backend where continueAsNew() did not cancel timers scheduled in the prior iteration, allowing stale TimerFired events to leak into the new execution. It also adjusts carryover external event ordering to align with real sidecar behavior.

Changes:

  • Track timer handles per orchestration instance and cancel them when processing continue-as-new.
  • Reorder continue-as-new “carryover events” so OrchestratorStarted / ExecutionStarted precede carried-over events.
  • Add regression tests covering timer cancellation and carryover event ordering scenarios.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

File Description
packages/durabletask-js/src/testing/in-memory-backend.ts Adds per-instance timer tracking/cancellation and fixes carryover event ordering for continue-as-new.
packages/durabletask-js/test/in-memory-backend.spec.ts Adds tests intended to validate timer cancellation and carryover ordering after continue-as-new.

Comment on lines +388 to +399
const orchestrator: TOrchestrator = async function* (ctx: OrchestrationContext, input: number): any {
if (input === 1) {
// First iteration: create a long timer, then immediately continue-as-new.
// The timer should be cancelled and NOT fire into the second iteration.
ctx.createTimer(60); // 60-second timer — should be cancelled
ctx.continueAsNew(2, false);
} else {
// Second iteration: create a short timer and wait for it.
// If the stale timer from iteration 1 leaks, it could complete
// the wrong task or cause unexpected events.
yield ctx.createTimer(0.05); // 50ms timer
iteration2TimerFired = true;
Comment on lines +442 to +445
expect(state).toBeDefined();
expect(state?.runtimeStatus).toEqual(OrchestrationStatus.COMPLETED);
expect(state?.serializedOutput).toEqual(JSON.stringify("carried-over"));
});
kaibocai
kaibocai previously approved these changes Jul 27, 2026
Copilot AI review requested due to automatic review settings July 27, 2026 18:07

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

Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.

Copilot AI review requested due to automatic review settings July 29, 2026 23:28
@YunchuWang
YunchuWang force-pushed the copilot-finds/bug/fix-continue-as-new-timer-leak branch from 6f022a1 to 2b5893e Compare July 29, 2026 23:28

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

Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.

kaibocai
kaibocai previously approved these changes Jul 31, 2026
…nBackend

Timer IDs are per-execution sequence numbers that restart at 1 after
continue-as-new, so a timer left pending from the previous iteration fires a
TimerFired event whose ID collides with the new iteration's first task and
completes it early. Cancel the instance's pending timers before resetting state.

Rebased onto current main. The test is a rewrite of the original: that version
used a 60s stale timer against a 50ms real timer, so the stale timer could never
fire within the test and it passed with and without the fix. This version
inverts the durations (50ms stale vs 1.5s real) and asserts on elapsed time.

Verified on current main: without the fix the orchestration completes in 58ms
(expected >1000); with the fix it takes 1562ms.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 5aaa70df-fae3-4570-aabe-0fc96cb869bc
Copilot AI review requested due to automatic review settings July 31, 2026 19:12
@YunchuWang
YunchuWang force-pushed the copilot-finds/bug/fix-continue-as-new-timer-leak branch from 2b5893e to 145b771 Compare July 31, 2026 19:12

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

Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.

@bachuv bachuv 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.

Approving, but please take a look at the open copilot comments!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[copilot-finds] Bug: InMemoryOrchestrationBackend continue-as-new does not cancel pending timers, causing stale events in new iteration

4 participants