Skip to content

feat: support AbortSignal in step.run for cooperative cancellation#37

Merged
coji merged 3 commits into
mainfrom
feat/abort-signal-step-run
Mar 4, 2026
Merged

feat: support AbortSignal in step.run for cooperative cancellation#37
coji merged 3 commits into
mainfrom
feat/abort-signal-step-run

Conversation

@coji
Copy link
Copy Markdown
Owner

@coji coji commented Mar 4, 2026

Summary

  • Pass an AbortSignal to step.run() callbacks, enabling cooperative cancellation of long-running steps
  • Signal is aborted both at step boundaries (before CancelledError) and mid-step via run:cancel event
  • Event listener is cleaned up via dispose() in the worker's finally block
  • Export RunCancelEvent type from package index

Fixes #26

Test plan

  • Signal is passed to callback and starts as not-aborted
  • Signal becomes aborted after cancel() during a long-running step
  • Cancelled step boundary prevents next step callback from executing
  • All existing tests pass (186/186)
  • Typecheck passes for durably and durably-react

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Steps now support cooperative cancellation: step callbacks receive an AbortSignal and will be aborted when a run is cancelled. Step contexts are also cleaned up automatically after execution.
  • Documentation
    • Updated API docs and examples to show the AbortSignal parameter and cancellation behavior.

Pass an AbortSignal to step.run callbacks so long-running steps can
break out early when a run is cancelled. The signal is aborted both
at step boundaries (before CancelledError is thrown) and mid-step
via the run:cancel event listener.

Fixes #26

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@vercel
Copy link
Copy Markdown

vercel Bot commented Mar 4, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
durably-demo Ready Ready Preview Mar 4, 2026 2:35pm

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Mar 4, 2026

Warning

Rate limit exceeded

@coji has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 21 minutes and 15 seconds before requesting another review.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: db8c6978-dc85-4b05-9ed9-944084078ebf

📥 Commits

Reviewing files that changed from the base of the PR and between 6cad18f and 6eb9340.

📒 Files selected for processing (2)
  • packages/durably/tests/shared/step.shared.ts
  • website/api/step.md
📝 Walkthrough

Walkthrough

This PR makes step execution cancellation cooperative by passing an AbortSignal into step.run callbacks. It wires an AbortController into the step context, aborts the signal on run cancellation, returns a disposable unsubscribe handle, and updates tests and docs to exercise and document the new behavior.

Changes

Cohort / File(s) Summary
Core Implementation
packages/durably/src/context.ts, packages/durably/src/worker.ts
createStepContext now creates an AbortController, subscribes to run:cancel to abort the signal, returns { step, dispose } and ensures dispose() is called from worker finally block. Step execution now passes controller.signal into step.run and throws if run already cancelled.
Type & Interface Updates
packages/durably/src/job.ts, packages/durably/src/index.ts
StepContext.run signature changed to `fn: (signal: AbortSignal) => T
Tests
packages/durably/tests/shared/step.shared.ts
Adds tests covering: signal is provided (not aborted initially), cancellation aborts the signal inside a running step, and cancellation at a step boundary prevents subsequent steps from running.
Documentation & Public API
packages/durably/docs/llms.md, website/api/index.md, website/api/step.md, website/public/llms.txt
Docs and examples updated to show step.run receives an AbortSignal, including examples forwarding the signal to fetch-like APIs and explaining cooperative cancellation.
Minor
packages/durably/src/index.ts
Exports updated to include RunCancelEvent in the public event types list.

Sequence Diagram(s)

sequenceDiagram
    participant User as User/API
    participant Worker as Worker
    participant Context as StepContext
    participant Controller as AbortController
    participant StepFn as Step Function

    User->>Worker: request cancellation (cancel run)
    Worker->>Worker: emit "run:cancel" event
    Worker->>Context: run:cancel event delivered
    Context->>Controller: controller.abort() (signal.aborted -> true)

    StepFn->>Controller: observes signal (checks signal.aborted / receives AbortError)
    alt Signal aborted
        StepFn->>StepFn: stop/throw early
        StepFn-->>Context: returns/throws cancelled result
    else Signal not aborted
        StepFn->>StepFn: continue normal work
        StepFn-->>Context: return result
    end

    Context->>Worker: step result or cancellation propagated
    Worker->>Worker: call dispose() to unsubscribe
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related issues

  • #26: Support AbortSignal in step.run for cooperative cancellation — this PR implements precisely the requested change to pass an AbortSignal into step.run and abort it on cancellation.
  • perf: optimize step boundary cancellation check #38: Related cancellation flow changes (uses AbortController and run:cancel event); code here appears to address the same cancellation propagation concerns.

Possibly related PRs

Poem

🐰 I carry a tiny signal bright,
Through steps that run into the night,
When cancel comes, I gently call—
Abort, unwind, no thud nor fall.
Hop on, brave code, and do what's right! 🥕

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 75.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The pull request title clearly and concisely describes the main feature being implemented: adding AbortSignal support to step.run for cooperative cancellation.
Linked Issues check ✅ Passed The pull request fully implements the proposed AbortSignal feature from issue #26, passing the signal to step.run callbacks, aborting on cancellation, and enabling cooperative cancellation in long-running steps.
Out of Scope Changes check ✅ Passed All changes are directly scoped to implementing AbortSignal support in step.run; no unrelated modifications were introduced outside the stated objectives.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/abort-signal-step-run

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (3)
packages/durably/tests/shared/step.shared.ts (1)

459-513: Strengthen the boundary-cancellation test by asserting signal.aborted directly.

The test currently verifies cancellation behavior, but not the signal state it names. Adding a direct assertion will tighten intent-to-assertion alignment.

✅ Suggested test tweak
     it('signal is aborted when cancellation is detected at step boundary', async () => {
       let step2Called = false
+      let step1SignalAborted = false
       let step1StartedResolve: () => void
       const step1StartedPromise = new Promise<void>((resolve) => {
         step1StartedResolve = resolve
       })
@@
       const signalBoundaryTestDef = defineJob({
         name: 'signal-boundary-test',
         input: z.object({}),
         run: async (step) => {
-          await step.run('step1', async () => {
+          await step.run('step1', async (signal) => {
             step1StartedResolve()
             // Wait until we are told to proceed (after cancel is issued)
             await proceedPromise
+            step1SignalAborted = signal.aborted
             return 'done'
           })
@@
       // step2 callback should never have been called
       expect(step2Called).toBe(false)
+      expect(step1SignalAborted).toBe(true)
     })
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/durably/tests/shared/step.shared.ts` around lines 459 - 513, The
test should also assert the AbortSignal state directly: inside
signalBoundaryTestDef's run, update the step.run('step1', ...) callback so that
after awaiting proceedPromise it checks the passed-in signal.aborted is true
(use the signal parameter provided to the step.run callback), then return; keep
the existing cancellation flow and final run.status assertion unchanged so the
test still verifies cancellation and that step2 was not executed.
packages/durably/src/context.ts (1)

66-123: Avoid classifying cooperative cancellation as step failure.

When the callback reacts to an aborted signal by throwing, Line [97] currently persists a failed step and emits step:fail. Consider short-circuiting aborted executions so cancellation remains cancellation in step telemetry too.

💡 Suggested adjustment
       } catch (error) {
+        // Preserve cancellation semantics for aborted in-flight steps
+        if (controller.signal.aborted) {
+          throw error instanceof CancelledError
+            ? error
+            : new CancelledError(run.id)
+        }
+
         // Save failed step
         const errorMessage =
           error instanceof Error ? error.message : String(error)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/durably/src/context.ts` around lines 66 - 123, The catch block that
currently treats all thrown errors as failures should detect cooperative
cancellation and record it as a cancellation instead: inside the catch (where
storage.createStep and eventEmitter.emit are used), check
controller.signal.aborted or error.name === 'AbortError' (or similar sentinel)
and in that branch call storage.createStep with status: 'cancelled' (using
run.id, name, index: stepIndex, startedAt) and emit a 'step:cancel' event (use
runId, jobName, stepName: name, stepIndex, labels, duration if available),
update the run.currentStepIndex as appropriate (same increment +
storage.updateRun(run.id, { currentStepIndex: stepIndex })) and then rethrow the
error; otherwise keep the existing failed-step logic for real errors.
website/api/step.md (1)

42-58: Consider constructing the URL dynamically with the page number.

The cooperative cancellation example is clear and demonstrates the key concepts well. However, the url variable is static while iterating through pages. For improved clarity, consider showing dynamic URL construction:

📝 Optional enhancement for the example
 await step.run('fetch-all-pages', async (signal) => {
   const results = []
+  const baseUrl = 'https://api.example.com/items'
   for (let page = 1; page <= totalPages; page++) {
     if (signal.aborted) break // Stop early on cancellation
-    const data = await fetch(url, { signal }) // Also aborts fetch
+    const data = await fetch(`${baseUrl}?page=${page}`, { signal }) // Also aborts fetch
     results.push(data)
   }
   return results
 })
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@website/api/step.md` around lines 42 - 58, The example loop inside step.run
uses a static url while iterating pages; update the example to construct the
request URL per iteration (e.g., build url using page or page query param) so
fetch receives the correct page-specific URL; modify the code inside the
step.run callback (the for loop that checks signal.aborted and calls fetch) to
build a dynamic URL per page before calling fetch.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@packages/durably/src/context.ts`:
- Around line 66-123: The catch block that currently treats all thrown errors as
failures should detect cooperative cancellation and record it as a cancellation
instead: inside the catch (where storage.createStep and eventEmitter.emit are
used), check controller.signal.aborted or error.name === 'AbortError' (or
similar sentinel) and in that branch call storage.createStep with status:
'cancelled' (using run.id, name, index: stepIndex, startedAt) and emit a
'step:cancel' event (use runId, jobName, stepName: name, stepIndex, labels,
duration if available), update the run.currentStepIndex as appropriate (same
increment + storage.updateRun(run.id, { currentStepIndex: stepIndex })) and then
rethrow the error; otherwise keep the existing failed-step logic for real
errors.

In `@packages/durably/tests/shared/step.shared.ts`:
- Around line 459-513: The test should also assert the AbortSignal state
directly: inside signalBoundaryTestDef's run, update the step.run('step1', ...)
callback so that after awaiting proceedPromise it checks the passed-in
signal.aborted is true (use the signal parameter provided to the step.run
callback), then return; keep the existing cancellation flow and final run.status
assertion unchanged so the test still verifies cancellation and that step2 was
not executed.

In `@website/api/step.md`:
- Around line 42-58: The example loop inside step.run uses a static url while
iterating pages; update the example to construct the request URL per iteration
(e.g., build url using page or page query param) so fetch receives the correct
page-specific URL; modify the code inside the step.run callback (the for loop
that checks signal.aborted and calls fetch) to build a dynamic URL per page
before calling fetch.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 9abceb81-9ec0-487c-a992-4ab572d17125

📥 Commits

Reviewing files that changed from the base of the PR and between 4d7a41a and 2b2905b.

📒 Files selected for processing (9)
  • packages/durably/docs/llms.md
  • packages/durably/src/context.ts
  • packages/durably/src/index.ts
  • packages/durably/src/job.ts
  • packages/durably/src/worker.ts
  • packages/durably/tests/shared/step.shared.ts
  • website/api/index.md
  • website/api/step.md
  • website/public/llms.txt

- Assert signal.aborted directly in boundary cancellation test
- Use dynamic URL in docs cooperative cancellation example

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@coji coji merged commit 070c66d into main Mar 4, 2026
4 checks passed
@coji coji deleted the feat/abort-signal-step-run branch March 5, 2026 12:14
@coderabbitai coderabbitai Bot mentioned this pull request Mar 26, 2026
10 tasks
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.

Support AbortSignal in step.run for cooperative cancellation

1 participant