Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions packages/durabletask-js/src/testing/in-memory-backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -567,6 +567,11 @@ export class InMemoryOrchestrationBackend {
const newInput = completeAction.getResult()?.getValue();
const carryoverEvents = completeAction.getCarryovereventsList();

// Cancel timers still pending from the previous iteration. Their timer IDs are
// sequence numbers that restart at 1 in the new iteration, so a leaked timer
// would fire a TimerFired event that completes an unrelated task.
this.cancelInstanceTimers(instance.instanceId);

// Reset instance state
instance.history = [];
instance.input = newInput;
Expand Down
40 changes: 40 additions & 0 deletions packages/durabletask-js/test/in-memory-backend.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -860,4 +860,44 @@ describe("In-Memory Backend", () => {
expect(backend.toClientStatus(suspendedInstance!.status)).toEqual(OrchestrationStatus.SUSPENDED);
});
});

it("should cancel pending timers on continue-as-new", async () => {
// Timer IDs are per-execution sequence numbers that restart at 1 after
// continue-as-new. A timer left pending from iteration 1 therefore fires a
// TimerFired event whose ID collides with iteration 2's first task, completing
// it long before it is due. Iteration 1's timer is deliberately short and
// iteration 2's is long, so a leak shows up as an early completion.
const staleTimerSeconds = 0.05;
const iteration2TimerSeconds = 1.5;
let iteration2TimerFired = false;

const orchestrator: TOrchestrator = async function* (ctx: OrchestrationContext, input: number): any {
if (input === 1) {
// Created but never awaited, then abandoned by continue-as-new.
ctx.createTimer(staleTimerSeconds);
ctx.continueAsNew(2, false);
} else {
yield ctx.createTimer(iteration2TimerSeconds);
iteration2TimerFired = true;
Comment on lines +874 to +881
return "done";
}
};

worker.addOrchestrator(orchestrator);
await worker.start();

const startedAt = Date.now();
const id = await client.scheduleNewOrchestration(orchestrator, 1);
const state = await client.waitForOrchestrationCompletion(id, true, 10);
const elapsedMs = Date.now() - startedAt;

expect(state).toBeDefined();
expect(state?.runtimeStatus).toEqual(OrchestrationStatus.COMPLETED);
expect(state?.serializedOutput).toEqual(JSON.stringify("done"));
expect(iteration2TimerFired).toBe(true);

// Without the cancellation, the stale 50ms timer completes iteration 2's timer
// almost immediately. The margin is deliberately wide to stay reliable in CI.
expect(elapsedMs).toBeGreaterThan(1000);
});
});
Loading