Skip to content

Add bulk cancellation for active jobs - #685

Merged
hedhoud merged 8 commits into
developfrom
fix/545-bulk-cancel-active-jobs
Jul 17, 2026
Merged

Add bulk cancellation for active jobs#685
hedhoud merged 8 commits into
developfrom
fix/545-bulk-cancel-active-jobs

Conversation

@hedhoud

@hedhoud hedhoud commented Jul 16, 2026

Copy link
Copy Markdown
Collaborator

Context

Admins can cancel one active task from the detail page, but cancelling several active jobs still required API calls or scripts.

Change

Add safe bulk cancellation to the existing Jobs table:

  • only active jobs are selectable for bulk cancellation
  • completed, failed, and cancelled jobs are disabled for selection
  • selected active jobs can be cancelled through the existing task cancellation endpoint
  • partial success/failure is reported through the existing toast pattern

Partially addresses #545.

Limitation

Retry is not implemented here. The current API does not expose a safe retry operation or enough durable submission data to recreate the original indexing request without risking duplicate or incorrect submissions. That should be a separate backend/API design before adding a retry button.

Validation

  • npm test -- data-table.test.tsx jobs/list.test.tsx
  • npm run lint
  • npm run build

Summary by CodeRabbit

  • New Features
    • Added row-level selection to shared data tables, with “select visible rows” respecting per-row eligibility.
    • Added bulk “cancel selected tasks” workflow for eligible (active) jobs with confirmation, success/failure counts, and pending-state controls.
  • Bug Fixes
    • Completed/failed/cancelled tasks can’t be selected or cancelled in bulk; selection auto-resets on tab/search changes.
    • Cancellation now returns a conflict when a task is already in a terminal state.
  • Tests
    • Added/expanded UI tests for disabled select-all/eligibility behavior and updated backend/unit tests for terminal-state cancellation handling.

@coderabbitai

coderabbitai Bot commented Jul 16, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@hedhoud, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 4 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: dd7efcf0-e4eb-4fef-9fea-22f60fdeba99

📥 Commits

Reviewing files that changed from the base of the PR and between ded3534 and 92d8a9a.

📒 Files selected for processing (5)
  • openrag/services/workers/dispatcher.py
  • openrag/services/workers/task_state.py
  • tests/unit/services/workers/test_task_state.py
  • ui/src/pages/admin/jobs/list.test.tsx
  • ui/src/pages/admin/jobs/list.tsx
📝 Walkthrough

Walkthrough

Bulk cancellation was added to the admin jobs list. The shared DataTable now supports row-level selection predicates, while backend cancellation checks terminal task states before updating task status.

Changes

Task cancellation and bulk selection

Layer / File(s) Summary
Terminal-state cancellation guard
openrag/core/models/..., openrag/services/workers/..., tests/unit/services/workers/test_dispatcher.py
Defines shared terminal states, conditionally marks active tasks as cancelled, prevents cancellation of terminal tasks, and updates dispatcher tests.
Cancellation API response handling
openrag/api/routers/admin/indexing.py, openrag/core/indexing/dispatcher.py
Returns a conflict response when cancellation fails because a task is already terminal and updates the cancellation contract documentation.
Jobs selection and bulk cancellation
ui/src/components/shared/data-table.*, ui/src/pages/admin/jobs/list.*
Adds row-level selection eligibility and bulk cancellation of active tasks with confirmation, outcome toasts, selection reset, and query refresh.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant JobListPage
  participant DataTable
  participant ConfirmDialog
  participant AdminCancellationAPI
  participant WorkerDispatcher
  participant TaskStateManager
  JobListPage->>DataTable: select active task rows
  JobListPage->>ConfirmDialog: request bulk cancellation confirmation
  ConfirmDialog-->>JobListPage: confirm cancellation
  JobListPage->>AdminCancellationAPI: cancel selected task IDs
  AdminCancellationAPI->>WorkerDispatcher: cancel task
  WorkerDispatcher->>TaskStateManager: set_cancelled_if_active
  TaskStateManager-->>WorkerDispatcher: conditional transition result
  AdminCancellationAPI-->>JobListPage: cancellation response
Loading

Possibly related PRs

Suggested labels: fix

Suggested reviewers: ahmath-gadji

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 14.29% 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 title accurately summarizes the main change: bulk cancellation added for selectable active jobs.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/545-bulk-cancel-active-jobs

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.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2973302e73

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread ui/src/pages/admin/jobs/list.tsx Outdated
Ahmath-Gadji
Ahmath-Gadji previously approved these changes Jul 17, 2026

@Ahmath-Gadji Ahmath-Gadji left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Reviewed. No correctness, security, or test-coverage issues — approving.

A few minor, non-blocking things worth a look (not required for merge):

  • bulkCancelMutation's onError in list.tsx is effectively dead code — mutationFn awaits Promise.allSettled, so per-task failures are already captured into { ok, failed } and the mutation itself won't reject in normal operation.
  • Styling inconsistency: "Cancel selected" uses variant="outline", while the equivalent single-task cancel on detail.tsx uses variant="destructive" for the same class of action.
  • Bulk "select all" only selects the current page (TanStack default), and a partial cancel failure clears the whole selection rather than leaving the failed ones selected for retry. Both reasonable trade-offs for this PR's scope, just flagging for awareness.

…-104451

# Conflicts:
#	ui/src/pages/admin/jobs/list.test.tsx
#	ui/src/pages/admin/jobs/list.tsx

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick comments (2)
ui/src/pages/admin/jobs/list.test.tsx (1)

24-30: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Prefer vi.importActual for pure utility functions.

Mocking pure functions like isActiveState duplicates business logic in the test file, which can lead to false positives if the actual implementation changes (e.g., if a new active status is introduced). Consider importing the actual module and only mocking the API calls.

♻️ Proposed refactor
-vi.mock("`@/lib/api/jobs`", () => ({
-  cancelTask: vi.fn(),
-  getQueueInfo: vi.fn(),
-  isActiveState: (state: string) => ["QUEUED", "SERIALIZING", "CHUNKING", "INSERTING"].includes(state),
-  isTerminalState: (state: string) => ["COMPLETED", "FAILED", "CANCELLED"].includes(state),
-  listTasks: vi.fn(),
-}));
+vi.mock("`@/lib/api/jobs`", async () => {
+  const actual = await vi.importActual<typeof import("`@/lib/api/jobs`")>("`@/lib/api/jobs`");
+  return {
+    ...actual,
+    cancelTask: vi.fn(),
+    getQueueInfo: vi.fn(),
+    listTasks: vi.fn(),
+  };
+});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ui/src/pages/admin/jobs/list.test.tsx` around lines 24 - 30, Update the jobs
module mock in the test to use vi.importActual and retain the real isActiveState
and isTerminalState implementations, while continuing to mock only cancelTask,
getQueueInfo, and listTasks.
ui/src/components/shared/data-table.tsx (1)

77-86: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider disabling the "Select visible rows" checkbox when no rows are selectable.

If a page only contains rows that cannot be selected (e.g., all tasks are completed), the header checkbox will have no effect when clicked. Disabling it in this state improves the user experience.

✨ Proposed UX improvement
-      header: ({ table }) => (
+      header: ({ table }) => {
+        const hasSelectableRows = table.getRowModel().rows.some((row) => row.getCanSelect());
+        return (
         <Checkbox
           checked={
             table.getIsAllPageRowsSelected() ||
             (table.getIsSomePageRowsSelected() && "indeterminate")
           }
           onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
+          disabled={!hasSelectableRows}
           aria-label="Select visible rows"
         />
-      ),
+        );
+      },
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ui/src/components/shared/data-table.tsx` around lines 77 - 86, Update the
header Checkbox in the table column definition to be disabled when the current
page has no selectable rows, using the table selection APIs or row-selection
predicate already used by the table. Preserve the existing checked state, toggle
behavior, and aria label when at least one visible row is selectable.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@ui/src/components/shared/data-table.tsx`:
- Around line 77-86: Update the header Checkbox in the table column definition
to be disabled when the current page has no selectable rows, using the table
selection APIs or row-selection predicate already used by the table. Preserve
the existing checked state, toggle behavior, and aria label when at least one
visible row is selectable.

In `@ui/src/pages/admin/jobs/list.test.tsx`:
- Around line 24-30: Update the jobs module mock in the test to use
vi.importActual and retain the real isActiveState and isTerminalState
implementations, while continuing to mock only cancelTask, getQueueInfo, and
listTasks.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: acc68d46-efc2-4841-bf24-e99dc7516783

📥 Commits

Reviewing files that changed from the base of the PR and between c5275fa and 57486c5.

📒 Files selected for processing (3)
  • ui/src/components/shared/data-table.tsx
  • ui/src/pages/admin/jobs/list.test.tsx
  • ui/src/pages/admin/jobs/list.tsx

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7d10ae650b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread ui/src/pages/admin/jobs/list.tsx
@hedhoud hedhoud added this to the v2.0.1 milestone Jul 17, 2026
@aditykris

Copy link
Copy Markdown
Collaborator

Terminal states are duplicated across three files with no shared source of truth

This PR adds a terminal-state check in three separate places, each spelled differently:

  • openrag/api/routers/admin/indexing.pyif task_state in {"COMPLETED", "FAILED", "CANCELLED"}:
  • openrag/services/workers/dispatcher.py_TERMINAL_STATES = frozenset({"COMPLETED", "FAILED", "CANCELLED"})
  • openrag/services/workers/task_state.py (set_cancelled_if_active) — if info is None or info.state in {"COMPLETED", "FAILED", "CANCELLED"}:

Same three strings, copy-pasted three times, nothing keeping them in sync. If a state is ever added/renamed, it's easy to update two of the three and end up with the route guard and the actor's own guard disagreeing about what's cancellable.

This also isn't the first independent formulation of this boundary: core/models/catalog.py already has a DocumentStatus enum with all 7 states (including these 3), but none of the three files here reference it — everyone compares raw string literals. And job_service.py:20 has a fourth version, _ACTIVE_STATES (the inverse set), also hand-maintained.

Suggest hoisting one TERMINAL_STATES constant , maybe from DocumentStatus (e.g. {DocumentStatus.COMPLETED, DocumentStatus.FAILED, DocumentStatus.CANCELLED}) owned by task_state.py since it's the actor holding state, and have dispatcher.py / indexing.py import it instead of re-declaring.


Possible redundant Ray call in dispatcher.py::cancel_task

state = await self._call(self._tsm.get_state.remote(task_id), ...)
if state in self._TERMINAL_STATES:
    return False

obj_ref = await self._call(self._tsm.get_object_ref.remote(task_id), ...)
if obj_ref is None:
    return False

state = await self._call(self._tsm.get_state.remote(task_id), ...)
if state in self._TERMINAL_STATES:
    return False

Terminal-state guards were spelled out independently in the route,
dispatcher, and TaskStateManager. Consolidate into
core.models.catalog.TERMINAL_TASK_STATES so the three stay in sync,
and drop the redundant pre-obj_ref state check in
WorkerDispatcher.cancel_task (the state check right before ray.cancel,
plus the atomic set_cancelled_if_active guard, already cover it).
@Ahmath-Gadji

Copy link
Copy Markdown
Collaborator

Good catch on both points. Fixed in commit 3d55607.

Duplicated terminal states: hoisted a single TERMINAL_TASK_STATES frozenset into core/models/catalog.py next to DocumentStatus (built from the enum members, as you suggested), and had indexing.py, dispatcher.py, and task_state.py import it instead of re-declaring the three string literals. Kept it scoped to the files this PR touches — job_service.py::_ACTIVE_STATES and mcp_service.py::_ACTIVE_STATES are the same pre-existing pattern but weren't touched by this PR, so left those as a separate follow-up rather than expanding this diff's blast radius.

Redundant Ray call: confirmed it was genuinely redundant — the state check right before ray.cancel(), followed by the atomic set_cancelled_if_active guard, already fully cover correctness (including the TOCTOU race your P1 comment flagged). Removed the earlier pre-get_object_ref state check, so cancel_task now does one get_state + one get_object_ref + the atomic set, instead of two get_state calls. Left a short comment explaining why the remaining check is there.

Ahmath-Gadji
Ahmath-Gadji previously approved these changes Jul 17, 2026

@Ahmath-Gadji Ahmath-Gadji left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Re-reviewing after the latest push (3d55607).

Status of everything raised in this PR's discussion:

  • codex P1 (stale-state race on bulk cancel): fixed in b4bce26 — client re-fetches and re-filters by isActiveState right before cancelling, server-side cancel_task/set_cancelled_if_active atomically reject terminal tasks.
  • codex P2 (hidden selections surviving a search change): fixed in b4bce26 — search onChange now clears rowSelection.
  • @aditykris — duplicated terminal-state literals across 3 files: fixed in 3d55607 — hoisted TERMINAL_TASK_STATES into core/models/catalog.py, all three call sites import it.
  • @aditykris — redundant Ray get_state call: fixed in the same commit — dropped the pre-get_object_ref state check; the remaining check + the atomic set_cancelled_if_active guard are sufficient.

My earlier review's non-blocking notes still stand as optional/out-of-scope for this PR (not required for merge): the mutation's onError handler is effectively unreachable given Promise.allSettled, "Cancel selected" uses variant="outline" vs. detail.tsx's variant="destructive" for the single-task cancel, and a partial cancel failure clears the whole selection rather than leaving failed rows selected for retry.

No correctness, security, or test-coverage issues. Approving.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@openrag/services/workers/dispatcher.py`:
- Around line 241-255: In openrag/services/workers/dispatcher.py lines 241-255,
update the cancellation flow to call set_cancelled_if_active atomically before
ray.cancel, remove the preceding get_state RPC and terminal-state check, and
return False without cancelling when the atomic update reports the task is not
active. In tests/unit/services/workers/test_dispatcher.py lines 218-239, remove
the get_state mock and assertions, configure set_cancelled_if_active to control
whether cancellation proceeds, and assert the updated call order and abort
behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 4e841814-3826-4f40-920b-b5027900a711

📥 Commits

Reviewing files that changed from the base of the PR and between 7d10ae6 and 3d55607.

📒 Files selected for processing (9)
  • openrag/api/routers/admin/indexing.py
  • openrag/core/indexing/dispatcher.py
  • openrag/core/models/__init__.py
  • openrag/core/models/catalog.py
  • openrag/services/workers/dispatcher.py
  • openrag/services/workers/task_state.py
  • tests/unit/services/workers/test_dispatcher.py
  • ui/src/pages/admin/jobs/list.test.tsx
  • ui/src/pages/admin/jobs/list.tsx
🚧 Files skipped from review as they are similar to previous changes (1)
  • ui/src/pages/admin/jobs/list.tsx

Comment thread openrag/services/workers/dispatcher.py Outdated
Calling ray.cancel() before the set_cancelled_if_active RPC left a
window where a killed worker could never report back if that RPC
then failed, stranding the task active forever. Atomically claim the
cancellation first and only send ray.cancel() once that succeeds, so
a failed cancel signal just leaves a task marked CANCELLED that later
self-corrects instead of a permanent zombie. Also drops the now
fully-redundant get_state precheck.
# Conflicts:
#	ui/src/pages/admin/jobs/list.test.tsx
Ahmath-Gadji
Ahmath-Gadji previously approved these changes Jul 17, 2026

@Ahmath-Gadji Ahmath-Gadji left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Re-reviewing after commit ded3534.

CodeRabbit flagged a real bug in my previous fix (ded3534 supersedes 3d55607): in WorkerDispatcher.cancel_task, calling ray.cancel() before the atomic set_cancelled_if_active RPC meant that if that RPC failed/timed out after the worker was killed, the task would be stuck active forever with no worker left to report completion — a permanent zombie. Fixed by claiming the cancellation atomically first and only sending ray.cancel() once that succeeds; a failed ray.cancel() now just leaves a task marked CANCELLED that self-corrects later instead of hanging forever. Also drops the now fully-redundant get_state precheck, so cancel_task is down to 2 remote calls. Tests updated to match, full unit suite (1721 tests) and all CI checks green.

Everything raised across this PR's review history is now addressed:

  • codex P1 (stale-state race on bulk cancel) — fixed upstream (b4bce26)
  • codex P2 (hidden selections surviving a search change) — fixed upstream (b4bce26)
  • duplicated terminal-state literals across 3 files — fixed (3d55607)
  • redundant get_state RPC — fixed, twice over (3d55607, then fully in ded3534)
  • zombie-task race on RPC failure after ray.cancel() — fixed (ded3534)

No remaining correctness, security, or test-coverage issues. Approving.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 800910a411

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread openrag/services/workers/dispatcher.py

@Ahmath-Gadji Ahmath-Gadji left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Re-approving after commit 92d8a9a.

Codex found one more real race in my previous fix (ded3534): claiming cancellation before ray.cancel() closed the RPC-failure zombie risk, but opened a window where a QUEUED task's worker could start process_file and unconditionally overwrite the CANCELLED claim via set_state("SERIALIZING"/"COMPLETED") before the cancel signal took effect. hedhoud fixed this at the right layer in 92d8a9a: TaskStateManager.set_state now refuses to leave CANCELLED for any other state, so any worker write in that window is a no-op. Covered by a new regression test, verified locally alongside the existing dispatcher suite.

All CI checks green: lint, tests, api-tests, milvus-integration, layer-import-guard, CodeRabbit. No outstanding comments from CodeRabbit, codex, or aditykris. Approving.

@hedhoud
hedhoud merged commit 7af801b into develop Jul 17, 2026
6 checks passed
@hedhoud
hedhoud deleted the fix/545-bulk-cancel-active-jobs branch July 17, 2026 15:57
Ahmath-Gadji pushed a commit that referenced this pull request Jul 20, 2026
`cancel_task` used to write CANCELLED to the jobs row unconditionally. Since
`get_object_ref` still answers for a finished task — the ref is only dropped
when the entry is evicted — a late `DELETE /task/{id}` reached that path for
work that had already succeeded, and durably recorded a COMPLETED file as
cancelled.

Rebasing onto #685 supplies the fix: `set_cancelled_if_active` refuses the
claim on a terminal task, and returning on that answer now short-circuits
before the durable write rather than after it. This pins the durable half,
which #685 alone does not cover.
Ahmath-Gadji pushed a commit that referenced this pull request Jul 20, 2026
`set_cancelled_if_active` is the only state write that does not call
`_mark_terminal`. Every sibling does — `set_state` and
`set_failed_if_not_cancelled` both register the transition — and eviction is
driven entirely off `terminal_at`, so an entry that never lands there is
retained for the lifetime of the detached actor: neither the 2000 cap nor the
1h TTL can reclaim it.

Nothing recovers it afterwards. `ray.cancel` raises `CancelledError`, a
`BaseException` that `process_file`'s `except Exception` sails straight past,
so no later write ever re-registers the task. Every user-initiated cancel
therefore leaks one `TaskInfo` — its details, its `user_index` entry, and its
pinned `object_ref` — which is verbatim the unbounded growth this branch
exists to fix.

The method predates the eviction machinery this branch adds (it arrived with
the bulk-cancel work in #685), and the two only met on the rebase; the gap is
the merge, not either change on its own. `#685`'s cancel-all UI fires one
cancel per selected task, so the leak scales with a single click.

`test_cancelled_tasks_are_evictable` did not catch it because it drives
`set_state(..., "CANCELLED")`, a path no cancellation actually takes. The new
test uses the entry point `cancel_task` really calls.
Ahmath-Gadji pushed a commit that referenced this pull request Jul 20, 2026
Cancelling a task that had not started yet leaked its reserved file slot
outright. After dispatch the worker owns the slot and only releases it in
`IndexerWorker.process_file`'s `finally`; when `ray.cancel` retires the task
before that body runs, the `finally` never executes and nothing else gives the
slot back. The CANCELLED row is terminal, so the loss is indistinguishable from
a clean cancel, and the uploader's quota is permanently one narrower.

`cancel_task` could not simply release, because a task cancelled *mid-flight*
does run its `finally` — releasing in both places would drive `file_count`
below reality and hand out free quota. The two outcomes were only
distinguishable by how far the worker happened to get.

`TaskStateManager` settles it. It is a single-threaded Ray actor and already
arbitrates this way (`set_cancelled_if_active`, `set_failed_if_not_cancelled`),
so a one-shot `claim_quota_release` compare-and-set is the idiomatic primitive
here: the worker and the canceller both ask, exactly one is told to release.
The dispatcher records `quota_reserved` on the task at admission so the actor
knows whether a slot is outstanding at all — `put_file` reuses an existing row
and reserves nothing.

The setup-failure release in `indexer_pool` goes through the same claim: a
cancellation *during* setup reaches both it and `cancel_task`.

This also closes the dispatch-partial-success double-release noted in the PR
description — the worker and the request teardown can no longer both consume
the same reservation.

Failure handling stays biased the way the rest of the branch is: an unreachable
arbiter releases anyway. An undercount is recoverable and self-heals under the
#676 reconciliation; a leak is permanent and silent. `_release_cancelled_slot`
is best-effort throughout, because a cancellation the user already asked for
and already saw must not fail on bookkeeping.

Worth doing now rather than deferring to #676: #685 shipped a cancel-all UI
that fires one cancel per selected task, so the leak had become reachable at
scale from a single click.
Ahmath-Gadji pushed a commit that referenced this pull request Jul 20, 2026
`cancel_task` used to write CANCELLED to the jobs row unconditionally. Since
`get_object_ref` still answers for a finished task — the ref is only dropped
when the entry is evicted — a late `DELETE /task/{id}` reached that path for
work that had already succeeded, and durably recorded a COMPLETED file as
cancelled.

Rebasing onto #685 supplies the fix: `set_cancelled_if_active` refuses the
claim on a terminal task, and returning on that answer now short-circuits
before the durable write rather than after it. This pins the durable half,
which #685 alone does not cover.
Ahmath-Gadji pushed a commit that referenced this pull request Jul 20, 2026
`set_cancelled_if_active` is the only state write that does not call
`_mark_terminal`. Every sibling does — `set_state` and
`set_failed_if_not_cancelled` both register the transition — and eviction is
driven entirely off `terminal_at`, so an entry that never lands there is
retained for the lifetime of the detached actor: neither the 2000 cap nor the
1h TTL can reclaim it.

Nothing recovers it afterwards. `ray.cancel` raises `CancelledError`, a
`BaseException` that `process_file`'s `except Exception` sails straight past,
so no later write ever re-registers the task. Every user-initiated cancel
therefore leaks one `TaskInfo` — its details, its `user_index` entry, and its
pinned `object_ref` — which is verbatim the unbounded growth this branch
exists to fix.

The method predates the eviction machinery this branch adds (it arrived with
the bulk-cancel work in #685), and the two only met on the rebase; the gap is
the merge, not either change on its own. `#685`'s cancel-all UI fires one
cancel per selected task, so the leak scales with a single click.

`test_cancelled_tasks_are_evictable` did not catch it because it drives
`set_state(..., "CANCELLED")`, a path no cancellation actually takes. The new
test uses the entry point `cancel_task` really calls.
Ahmath-Gadji pushed a commit that referenced this pull request Jul 20, 2026
Cancelling a task that had not started yet leaked its reserved file slot
outright. After dispatch the worker owns the slot and only releases it in
`IndexerWorker.process_file`'s `finally`; when `ray.cancel` retires the task
before that body runs, the `finally` never executes and nothing else gives the
slot back. The CANCELLED row is terminal, so the loss is indistinguishable from
a clean cancel, and the uploader's quota is permanently one narrower.

`cancel_task` could not simply release, because a task cancelled *mid-flight*
does run its `finally` — releasing in both places would drive `file_count`
below reality and hand out free quota. The two outcomes were only
distinguishable by how far the worker happened to get.

`TaskStateManager` settles it. It is a single-threaded Ray actor and already
arbitrates this way (`set_cancelled_if_active`, `set_failed_if_not_cancelled`),
so a one-shot `claim_quota_release` compare-and-set is the idiomatic primitive
here: the worker and the canceller both ask, exactly one is told to release.
The dispatcher records `quota_reserved` on the task at admission so the actor
knows whether a slot is outstanding at all — `put_file` reuses an existing row
and reserves nothing.

The setup-failure release in `indexer_pool` goes through the same claim: a
cancellation *during* setup reaches both it and `cancel_task`.

This also closes the dispatch-partial-success double-release noted in the PR
description — the worker and the request teardown can no longer both consume
the same reservation.

Failure handling stays biased the way the rest of the branch is: an unreachable
arbiter releases anyway. An undercount is recoverable and self-heals under the
is best-effort throughout, because a cancellation the user already asked for
and already saw must not fail on bookkeeping.

Worth doing now rather than deferring to #676: #685 shipped a cancel-all UI
that fires one cancel per selected task, so the leak had become reachable at
scale from a single click.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

fix Fix issue

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants