Skip to content

Bulk cancel jobs by filter (tags/batch_id/queue) #54

Description

@rcbevans

I tag jobs per tenant when enqueueing (EnqueueItem.tags), and when a tenant needs to be offboarded — or a particular run needs aborting — I want to cancel everything matching that tag. The only cancel surface today is per-job: JobsClient.cancel(job_id), TaskQ.cancel(job_id), JobHandle.cancel(). So the operator path is paginate-and-cancel:

cursor = None
while True:
    page = await client.list(
        JobFilter(tags=("tenant:acme",), active=True, limit=100, cursor=cursor)
    )
    for row in page.jobs:
        await client.cancel(row.id, reason="tenant offboarded")
    if page.next_cursor is None:
        break
    cursor = page.next_cursor

Two problems. First, it's slow for large tenants — one round trip per job on top of the page reads. Second, it's racy: workers keep dispatching jobs from pages I haven't reached yet, and nothing stops new matching jobs from landing behind my cursor, so "cancel everything for this tenant" needs an outer retry loop to actually converge. The fleet is actively working against me while I iterate.

What I'd rather write:

result = await client.cancel_where(
    JobFilter(tags=("tenant:acme",), active=True),
    reason="tenant offboarded",
)

i.e. one set-based write that issues cancel requests (phase 1, cooperative) for every row matching the filter, returning counts and maybe the affected ids for observability. The per-row semantics already exist in write_cancel_request and the cancellation state machine; this is the same write over a filter instead of a single id.

Open questions from my side: should matching rows in pending/scheduled go straight to terminal cancelled instead of through the phase-1 request (they're not running, so there's nothing to cooperate with)? And is a guardrail wanted — e.g. refusing a filter with no predicates so you can't cancel the whole table by accident?

Is a bulk-cancel primitive something you'd consider, or is the paginated loop the intended answer? Happy to be told I'm missing an existing escape hatch.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions