Skip to content

feat: bulk cancel by filter + sub-job tags (#54, #57) - #65

Open
rcbevans wants to merge 16 commits into
mainfrom
spec/cancel-filter
Open

feat: bulk cancel by filter + sub-job tags (#54, #57)#65
rcbevans wants to merge 16 commits into
mainfrom
spec/cancel-filter

Conversation

@rcbevans

@rcbevans rcbevans commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds set-based cancel_where(JobFilter) for bulk cancellation in a single SQL round-trip, and widens SubJobEnqueuer.enqueue() with tags, inherit_tags, and missing timeout fields so sub-jobs are visible to tag-based filters via parent-tag inheritance.

Issues addressed

  • #54 — bulk cancel by filter
  • #57 — sub-job tags

Implementation

Bulk cancel by filter (#54)

BulkCancelResult (backend/_protocol.py, re-exported through typesclient__init__): frozen Pydantic model with cancelled_directly, cancel_requested, cancelled_ids, cancel_requested_ids, and total_affected property. Defined in _protocol.py (not types.py) to avoid the circular import types → _protocol → types.

EmptyFilterError (exceptions.py): TaskQError subclass. Raised by JobsClient.cancel_where when the filter has no predicates (would cancel the entire table). Bypassed with allow_empty_filter=True.

build_filter_conditions() (backend/_filter_sql.py, new): extracted from _reads._list_jobs so reads and cancel share identical filter semantics. Returns FilterSQL(conditions, params). Translates only predicate fields: queue, status, actor, identity_key, batch_id (via metadata @> $N::jsonb), tags (via tags && $N::text[]), active (expands to status = ANY($N) with active/terminal status sets). cursor, limit, and order_by are excluded — callers handle them separately.

JobsClient.cancel_where(filter, reason, *, allow_empty_filter=False) (client/_jobs.py): client-layer method with the empty-filter guardrail, taskq.cancellation.requested counter increment, and schema-error translation. Returns BulkCancelResult.

TaskQ.cancel_where() (client/_taskq.py): delegate to JobsClient.

Backend protocol (backend/_protocol.py): cancel_where(filter, reason) -> BulkCancelResult added. Purely additive — no protocol version bump; a backend lacking the method raises AttributeError loudly.

PostgresBackend.cancel_where() (backend/postgres.py): delegates to _cancel_bulk._cancel_where(), then sends batched NOTIFY via SELECT pg_notify(channel, payload) FROM unnest($1::text[], $2::text[]) for running jobs (fleet channel + per-worker channel). Increments taskq.cancel.notify_sent once per notified job.

_cancel_bulk._cancel_where() (backend/_cancel_bulk.py, new): single CTE statement with two UPDATEs:

  • cancelled CTE: pending/scheduled → terminal cancelled (captures prev_status for accurate state_change events)
  • cancel_requested CTE: running + cancel_phase=0cancel_phase=1 (cooperative)

EPQ-safe predicates are duplicated in each UPDATE's own WHERE clause (not just in the matching CTE) so READ COMMITTED re-evaluation works correctly. ORDER BY id in the matching CTE ensures deterministic lock ordering. Events inserted via executemany within the same transaction. Deadlock retry: 3 attempts with jittered exponential backoff. Returns (BulkCancelResult, list[NotifyTarget]).

InMemoryBackend.cancel_where() (testing/_cancel_bulk.py, new): sanitizes the filter (limit=2^31, cursor=None, order_by=None) before calling _list_jobs to avoid pagination capping. Same event parity as Postgres: state_change + cancel_request for pending/scheduled, cancel_request only for running. Wakes cancel subscribers for running jobs.

Sub-job tags (#57)

SubJobEnqueuer.enqueue() (client/_enqueuer.py): new keyword-only params tags, inherit_tags=True, schedule_to_close, start_to_close, heartbeat_timeout. Tag resolution via _resolve_tags():

inherit_tags tags Result
True (default) None Parent job's tags
True (default) ["new"] Parent tags + explicit, merged (union, parent-first, deduped)
False None () (current behavior)
False ["new"] ("new",) only

Parent tag propagation via contextvars.ContextVar (_parent_tags_var in _enqueuer.py): the consumer sets parent tags before actor invocation and resets after. asyncio Tasks copy context, so concurrent consumers each see their own value — no per-instance field race.

Consumer integration (worker/_consumer.py, worker/run.py): set_parent_tags(tuple(job.tags)) called before actor invocation, _parent_tags_var.reset(token) in finally. The stub consumer in run.py does this unconditionally; the real consumer always sets parent tags (per-call inherit_tags=False is the opt-out).

Breaking changes

  • SubJobEnqueuer.enqueue() signature widened with new keyword-only params (tags, inherit_tags, schedule_to_close, start_to_close, heartbeat_timeout). All default to backward-compatible values — pre-existing callers are unaffected.
  • Backend protocol gains cancel_where: additive, no version bump. Backends without the method raise AttributeError when called (loud failure, not silent).

Test coverage

File Scope What it covers
tests/test_filter_sql.py Unit build_filter_conditions for all predicate fields, empty filter, combined filters, cursor/order_by exclusion. SQL injection safety: metacharacter payloads parameterized, conditions contain only column names + $N placeholders
tests/test_bulk_cancel_types.py Unit BulkCancelResult construction, frozen enforcement, zero counts. EmptyFilterError is TaskQError subclass, message mentions guardrail
tests/test_cancel_where.py Unit (in-memory) Pending→cancelled, running→cooperative, mixed statuses, no matches, already-cancelled, batch_id filter, queue+actor filter, active filter, ignores filter.limit (pagination guard)
tests/test_cancel_where_pg.py Integration (Postgres) Bulk cancel pending/scheduled/running/mixed, filter by tags/queue/actor/batch_id, event insertion, deadlock retry (succeed-on-retry, exhausted-raises, no-retry-on-success)
tests/test_cancel_where_client.py Unit (client) Empty filter guardrail, allow_empty_filter bypass, counter increment, schema error translation, TaskQ delegate
tests/test_sub_job_tags.py Unit Tag inheritance (default inherit, explicit tags, merge, inherit_tags=False, backward compat)
tests/test_backend_protocol.py Unit cancel_where in protocol member set, member count updated
tests/e2e/test_sub_job_tags.py E2E Real pipeline: tagged parent → sub-job tag inheritance and merge in a worker container
tests/e2e/test_cancellation.py E2E Bulk cancel in a real worker container (merged from standalone file)

Security

The filter→SQL builder (_filter_sql.py) uses positional parameters ($N) for all user-supplied values. Column names are hardcoded literals, never derived from input. No f-string interpolation of user values into SQL condition strings. Dedicated SQL injection tests (TestSQLInjectionSafety in test_filter_sql.py) verify that metacharacter payloads ('; DROP TABLE jobs; --, etc.) end up in params, not in conditions.


Closes #54, closes #57

@rcbevans
rcbevans requested review from XBeg9, clinzy and kjw-azx July 30, 2026 01:37
@rcbevans rcbevans self-assigned this Jul 30, 2026
@rcbevans
rcbevans force-pushed the spec/cancel-filter branch from f3f7875 to da3f96e Compare July 30, 2026 03:52
Base automatically changed from feat/e2e-test-suite to main July 30, 2026 04:32
rcbevans added 15 commits July 29, 2026 21:55
Extract the predicate-only condition builder from _reads._list_jobs
into a shared build_filter_conditions() helper so cancel_where can
reuse the exact same filter logic. Cursor/limit/order_by stay in
_list_jobs and are appended after the shared builder call.

All existing test_job_filter.py and test_postgres_reads.py tests
verify no regression.
Extract the predicate-only condition builder from _reads._list_jobs
into a shared build_filter_conditions() helper so cancel_where can
reuse the exact same filter logic. Cursor/limit/order_by stay in
_list_jobs and are appended after the shared builder call.

FilterSQL uses immutable tuples for conditions/params (2P6 review
feedback — mutable lists in a frozen dataclass only prevent
reassignment, not in-place mutation).

All existing test_job_filter.py tests verify no regression.
Pin the security property that user-supplied values are always
parameterized via  positional binding, never interpolated into
the SQL condition string. Tests verify:
- SQL metacharacter payloads in queue/actor/tags/identity_key
  appear in params, not in conditions
- Conditions only contain known column names, operators, and
   placeholders
BulkCancelResult is defined in _protocol.py (next to ScheduleRecord)
to avoid a circular import — types.py already imports from _protocol,
so a back-edge would fail at import time. Re-exported through the
same chain as CancelResult: _protocol → types → client → __init__.

EmptyFilterError is the guardrail exception for cancel_where with
no filter predicates.

Also fixes stale 'pydantic-free' docstring in types.py (ScheduleRecord
already broke that claim).
Purely additive — no BACKEND_PROTOCOL_VERSION bump required. A v3
implementation lacks the method and the client raises AttributeError
loudly (not silent misbehavior). Updates member count test (36→37)
and protocol docstring (33→34 methods total).
Bulk cancel for the in-memory backend following the same module-level
function pattern as _reads.py, _terminal.py, etc. Sanitizes the filter
(limit=2**31, cursor=None, order_by=None) before reusing _list_jobs to
prevent silent capping (H3 fix). Skips running jobs already in
cooperative cancel (cancel_phase >= 1). Events match single-job
write_cancel_request: state_change + cancel_request for pending/
scheduled, cancel_request only for running.
PostgresBackend: single CTE-based SQL statement with EPQ-safe
predicates duplicated in each UPDATE's WHERE clause. Events inserted
via executemany in the same transaction. Batched post-commit NOTIFY
for running jobs. Deadlock retry (3 attempts, jittered backoff) in
the backend, not the client (exception type is asyncpg-specific).

JobsClient: cancel_where with EmptyFilterError guardrail for empty
filters (allow_empty_filter=True bypasses). Counter incremented once
per call. Schema errors translated to SchemaNotMigratedError.

TaskQ: thin delegate to JobsClient.cancel_where.

NotifyTarget NamedTuple prevents positional-index confusion (2P7).
All user values use $N positional binding — reason serialized via
jsonb_param, never f-string interpolation (H2 fix).
Add tags, inherit_tags, schedule_to_close, start_to_close, and
heartbeat_timeout parameters to SubJobEnqueuer.enqueue(). Parent tags
are propagated via a contextvars.ContextVar set by the consumer before
actor invocation -- asyncio Tasks copy the context, so concurrent
consumers each see their own parent tags.

Tag inheritance defaults to True with a fleet-level kill switch
(sub_job_inherit_tags worker setting, TASKQ_SUB_JOB_INHERIT_TAGS env
var). When disabled, the consumer skips set_parent_tags() and the
ContextVar remains at its () default -- identical to pre-upgrade behavior.

_resolve_tags handles the tags=[] edge case intuitively: an empty list
means 'no additional tags', so parent tags are still inherited (2P1
review feedback).
Task 9: Pipeline actors (pipeline_stage, tagged_pipeline_stage) verify
sub-job tag inheritance and tag merging in a real worker container.
Tests: inherits parent tags, explicit tags merge with inherited.

Task 10: Bulk cancel E2E tests against real Postgres + worker:
- pending jobs by tag (5 cancelled, 2 untagged survive)
- running jobs cooperative cancel (assert total_affected >= 1 per 2P3)
- empty filter guardrail
- batch_id filter

All E2E tests marked @pytest.mark.e2e (manual-only, requires Docker).
Pre-1.0 library — no installed base to protect. The consumer always
sets parent tags; per-call inherit_tags=False is the opt-out for
callers who need it. Merges cancel_where E2E tests into
test_cancellation.py (same cancel protocol, single file).
Update jobs-clients.md: new cancel_where section with BulkCancelResult
fields, guardrail, snapshot boundary, and tenant-scale guidance.
Update sub-job enqueue signature with tags/inherit_tags/schedule_to_close/
start_to_close/heartbeat_timeout. Document tag inheritance semantics
table and blast-radius. Remove stale exclusion list.

Update architecture.md: add cancel_where to Backend protocol listing.

Update cancellation.md: add cancel_where as a cancellation method.
L1: Replace assert with explicit RuntimeError in _cancel_bulk.py
  (assert strips under -O, leaving an opaque TypeError)
L2: Remove redundant TYPE_CHECKING import of asyncpg in _cancel_bulk.py
  (already imported at module level for DeadlockDetectedError)
L3: Add deadlock retry unit tests (succeed-on-retry, exhausted-raises,
  no-retry-on-success) — mocks the pool to avoid PG dependency
Concurrency:
- F1: Move ContextVar token reset to outermost finally in _consumer.py
  and run.py — prevents parent-tags leak if exception occurs before
  inner try block (Critical: tag data corruption across jobs)
- F3: Remove status='running' filter from cancel_requested CTE
  subquery — a pending→running transition between snapshot and UPDATE
  previously escaped both CTEs; now the EPQ guard on the UPDATE handles
  it correctly
- F4: NOTIFY failure after commit no longer masks BulkCancelResult —
  best-effort with warning log (workers discover cancel via heartbeat
  poll regardless)

Architecture:
- F4: Add JobFilter.has_predicates() method — guardrail uses it instead
  of fragile field-by-field check (Open-Closed Principle)
- F5: enqueue_batch fallback now passes tags and inherit_tags=False
  from EnqueueItem (was silently dropping them)

DRY:
- Extract _cancel_notify_payload/_cancel_notify_channels helpers in
  postgres.py — shared between single-job and bulk cancel paths

Docs:
- Fix protocol docstring method count (34 async + 2 sync = 36)
- Fix pre-existing colon-form tag examples in jobs-clients.md

Hygiene:
- Remove NotifyTarget from __all__ in _cancel_bulk.py

Tests:
- Parameter numbering () sequential verification
- active=False, status-sequence, identity_key happy-path in filter_sql
- _resolve_tags branch: inherit=True, no parent, explicit tags
- Empty filter guardrail tags=() sub-branch
- Deadlock during executemany retry
- NotifyTarget filters None worker_id
Critical:
- C1: Redesign cancel_where SQL as two-statement pattern in same tx
  (pending/scheduled first, running second with fresh snapshot) —
  eliminates the race where pending→running escapes both CTEs
- C2: Register pipeline_stage/tagged_pipeline_stage in worker_entry.py
  ACTORS dict — e2e tests were broken without this

High:
- H1: Fix all pyright errors in test files (typed dict generics,
  annotated mock params, guard re.search result)

Medium:
- M1: Add guardrail contract note to Backend.cancel_where docstring
- M2: Fix has_predicates() docstring (manual sync, not automatic)
- M3: Add parent_tags() context manager, drop _parent_tags_var from
  __all__, use context manager in run.py, document contract in module
  docstring
- M4: CHANGELOG [Unreleased] entries + actors.md tag inheritance note
- M9: BulkCancelResult ID fields are tuple[UUID, ...] not list[UUID]
- M11: Fix false heartbeat_timeout 'actor default' docs claim

Low:
- Fix _filter_sql.py docstring (equivalence claim corrected)
- Fix FilterSQL frozen docstring (params contains mutable lists)
- Fix postgres.py module docstring (add _cancel_bulk, _filter_sql)
- Add JobFilter docstring mention of cancel_where
- Add pyproject.toml S608/SIM117/S311 rationale comments
- Use public 'from taskq import JobFilter' in e2e tests and docs
- Simplify _resolve_tags with dict.fromkeys dedup
- README: add bulk cancel to capability list
- Remove NotifyTarget from _cancel_bulk __all__
@rcbevans
rcbevans force-pushed the spec/cancel-filter branch from da3f96e to 20245b1 Compare July 30, 2026 04:57
…ge_with_parent

Stage 3 inherits ALL parent tags (run-tag + stage-2) per the tag
inheritance design (unit-tested in test_inherit_and_merge_tags).
The e2e assertion expected only 1 job with stage-2 tag, but stage 3
legitimately inherited stage-2 from stage 2, so the correct count is 2.

Updated the assertion to expect 2 jobs and verify the actual stage-2
job (excluding stage-3 which inherited it) carries both the parent
run tag and the explicit stage-2 tag.
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.

1 participant