feat: bulk cancel by filter + sub-job tags (#54, #57) - #65
Open
rcbevans wants to merge 16 commits into
Open
Conversation
rcbevans
force-pushed
the
spec/cancel-filter
branch
from
July 30, 2026 03:52
f3f7875 to
da3f96e
Compare
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
force-pushed
the
spec/cancel-filter
branch
from
July 30, 2026 04:57
da3f96e to
20245b1
Compare
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds set-based
cancel_where(JobFilter)for bulk cancellation in a single SQL round-trip, and widensSubJobEnqueuer.enqueue()withtags,inherit_tags, and missing timeout fields so sub-jobs are visible to tag-based filters via parent-tag inheritance.Issues addressed
Implementation
Bulk cancel by filter (#54)
BulkCancelResult(backend/_protocol.py, re-exported throughtypes→client→__init__): frozen Pydantic model withcancelled_directly,cancel_requested,cancelled_ids,cancel_requested_ids, andtotal_affectedproperty. Defined in_protocol.py(nottypes.py) to avoid the circular importtypes → _protocol → types.EmptyFilterError(exceptions.py):TaskQErrorsubclass. Raised byJobsClient.cancel_wherewhen the filter has no predicates (would cancel the entire table). Bypassed withallow_empty_filter=True.build_filter_conditions()(backend/_filter_sql.py, new): extracted from_reads._list_jobsso reads and cancel share identical filter semantics. ReturnsFilterSQL(conditions, params). Translates only predicate fields:queue,status,actor,identity_key,batch_id(viametadata @> $N::jsonb),tags(viatags && $N::text[]),active(expands tostatus = ANY($N)with active/terminal status sets).cursor,limit, andorder_byare 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.requestedcounter increment, and schema-error translation. ReturnsBulkCancelResult.TaskQ.cancel_where()(client/_taskq.py): delegate toJobsClient.Backendprotocol (backend/_protocol.py):cancel_where(filter, reason) -> BulkCancelResultadded. Purely additive — no protocol version bump; a backend lacking the method raisesAttributeErrorloudly.PostgresBackend.cancel_where()(backend/postgres.py): delegates to_cancel_bulk._cancel_where(), then sends batched NOTIFY viaSELECT pg_notify(channel, payload) FROM unnest($1::text[], $2::text[])for running jobs (fleet channel + per-worker channel). Incrementstaskq.cancel.notify_sentonce per notified job._cancel_bulk._cancel_where()(backend/_cancel_bulk.py, new): single CTE statement with two UPDATEs:cancelledCTE: pending/scheduled → terminalcancelled(capturesprev_statusfor accuratestate_changeevents)cancel_requestedCTE: running +cancel_phase=0→cancel_phase=1(cooperative)EPQ-safe predicates are duplicated in each UPDATE's own WHERE clause (not just in the
matchingCTE) so READ COMMITTED re-evaluation works correctly.ORDER BY idin thematchingCTE ensures deterministic lock ordering. Events inserted viaexecutemanywithin 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_jobsto avoid pagination capping. Same event parity as Postgres:state_change+cancel_requestfor pending/scheduled,cancel_requestonly for running. Wakes cancel subscribers for running jobs.Sub-job tags (#57)
SubJobEnqueuer.enqueue()(client/_enqueuer.py): new keyword-only paramstags,inherit_tags=True,schedule_to_close,start_to_close,heartbeat_timeout. Tag resolution via_resolve_tags():inherit_tagstagsTrue(default)NoneTrue(default)["new"]FalseNone()(current behavior)False["new"]("new",)onlyParent tag propagation via
contextvars.ContextVar(_parent_tags_varin_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)infinally. The stub consumer inrun.pydoes this unconditionally; the real consumer always sets parent tags (per-callinherit_tags=Falseis 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.Backendprotocol gainscancel_where: additive, no version bump. Backends without the method raiseAttributeErrorwhen called (loud failure, not silent).Test coverage
tests/test_filter_sql.pybuild_filter_conditionsfor all predicate fields, empty filter, combined filters, cursor/order_by exclusion. SQL injection safety: metacharacter payloads parameterized, conditions contain only column names +$Nplaceholderstests/test_bulk_cancel_types.pyBulkCancelResultconstruction, frozen enforcement, zero counts.EmptyFilterErrorisTaskQErrorsubclass, message mentions guardrailtests/test_cancel_where.pyfilter.limit(pagination guard)tests/test_cancel_where_pg.pytests/test_cancel_where_client.pyallow_empty_filterbypass, counter increment, schema error translation,TaskQdelegatetests/test_sub_job_tags.pyinherit_tags=False, backward compat)tests/test_backend_protocol.pycancel_wherein protocol member set, member count updatedtests/e2e/test_sub_job_tags.pytests/e2e/test_cancellation.pySecurity
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 (TestSQLInjectionSafetyintest_filter_sql.py) verify that metacharacter payloads ('; DROP TABLE jobs; --, etc.) end up inparams, not inconditions.Closes #54, closes #57