feat(batch): failure policy, empty-batch safety, batch discovery (#55, #58, #59) - #62
Open
rcbevans wants to merge 21 commits into
Open
feat(batch): failure policy, empty-batch safety, batch discovery (#55, #58, #59)#62rcbevans wants to merge 21 commits into
rcbevans wants to merge 21 commits into
Conversation
rcbevans
added a commit
that referenced
this pull request
Jul 30, 2026
Critical: - C2: retried failures no longer count toward abort — handlers now return 'scheduled' for retries, hook skips non-terminal outcomes - C3: batch row created when failure_policy OR finalizer is set (finalizer-only batches now discoverable via list_batches) High: - H1: BatchFailurePolicy base is now abstract (TypeError on direct instantiation); failure_threshold field on base, read polymorphically - H2: blocking-mode wait_for_batch no longer raises Snooze for aborted batches with in-flight jobs — returns status so poll loop continues - H3: stale-batch sweep catches UndefinedTableError (rolling-deploy safe) - H4: streaming atomic path pairs handles with correct per-item result_adapter and computes was_existing from row/args id - H5: build_enqueue_args strips caller-supplied metadata.batch_id (security boundary — library-injected only) - H6: streaming atomic path no longer materializes the full iterable (chunked contract preserved); max_pending check applied Medium: - M1: max_pending off-by-one fixed (> not >=) - M2: duplicate batch_id raises BatchIdExistsError (typed, not raw PG error) - M3: caller-connection path creates batch row before job inserts - M4: finalizer_job_id uses returned row id (handles idempotency collision) - M5: expected_size>0 with zero jobs raises EmptyBatchError - M6: streaming validation failure wraps in PayloadValidationError with index - M7: hook best-effort semantics documented (crash window, stale sweep) - M8: in-memory create_batch raises on duplicate (parity with PG) - M9: running jobs finish on abort — documented on AbortBatchAfter + BatchAbortedError - M10: increment/reset SQL adds AND status='active' (no post-abort straggler reset) - M11: prune_old_batches skipped when all retentions disabled - M12: CHECK constraints on batches table (failure_threshold>=1, expected_size>=0, consecutive_failures>=0) - M13: BatchHandle docstring states finalizer invariant explicitly - M14: CHANGELOG entries + enqueue_batch docstring updated - M15: abort-wins-over-complete test + strengthened Hypothesis property oracle - M16: PG tests — concurrent increment, rolling-deploy, stale-batch negative, abort cancel columns - M17: e2e abort×finalizer test + discovery test fix + batch B counts asserted - M18: verified e2e gate hook exists (no fix needed) - M19: JobsClient.get_batch + TaskQ.get_batch delegates added Low: - DRY: terminal-status SQL built from TERMINAL_STATUSES (no drift) - DRY: MAX_BATCH_SIZE constant replaces magic 1000 - parse_batch_status() mirrors parse_retry_kind/parse_cancel_phase - BatchStatus type alias added - Docs: finalizer timing, running-job abort semantics, originating_actor, workers.md leader duties, testing.md batch-policy simulation - BatchAbortedError threshold accepts int | None - EmptyBatchError message includes remediation hint - batch.py Provides list updated with BatchSummary + apply_batch_terminal_outcome - test_sync_actor: updated for correct 'scheduled' outcome on retry 4184 tests pass, 0 failures. ruff + pyright clean.
rcbevans
force-pushed
the
spec/batch-subsystem
branch
from
July 30, 2026 04:34
8ad3d60 to
d39f9c6
Compare
…rotocol stubs - New _batch.py companion module with all 10 batch protocol methods - InMemoryBackend: _batches dict + thin delegate methods - PostgresBackend: NotImplementedError stubs for isinstance compatibility - Fixed: finalizer not stamped with metadata.batch_id (deadlock prevention) - Fixed: BatchCounts computed in single pass, not 7
- New _batch_sql.py companion module with BatchSql dataclass, SQL templates, and module-level async functions following _dispatch_sql.py pattern - PostgresBackend: real implementations replacing NotImplementedError stubs - abort_batch: hardcoded error message as SQL literal (no injection risk), two statements wrapped in transaction for atomicity - enqueue_batch_atomic: lazy iterable consumption with try/except rollback on generator failure (MEDIUM-4) - Finalizer NOT stamped with metadata.batch_id (deadlock prevention) - All user values use $N parameter binding; schema validated against _IDENT_RE
…o client + facade - enqueue_batch: new failure_policy and finalizer keyword params - BatchHandle: new finalizer_handle field (MEDIUM-1) - BatchSummary: frozen dataclass for list_batches results - enqueue_batch_streaming: unbounded iterable chunking - list_batches: batch discovery with BatchFilter (HIGH-4) - TaskQ facade: forwards all new params, adds streaming + list_batches delegates - Finalizer never stamped with metadata.batch_id (deadlock prevention) - originating_actor=None for direct JobsClient calls (LOW-1)
- apply_batch_terminal_outcome: shared hook for abort + completion, called by both worker consumer and in-memory runner - try/except guard on hook calls (HIGH-3): batch-policy failure never affects job's terminal state - wait_for_batch: new expect_at_least, on_empty, exclude_job_id params - expect_at_least checked before batches-row total==0 return (CRITICAL-3) - aborted-batch detection raises BatchAbortedError - finalizer_job_id auto-excluded from count query - UndefinedTableError caught for rolling-deploy window - Fixed: AttemptOutcome now includes 'scheduled' (pre-existing type gap) - Hypothesis property test for abort policy (CRITICAL-2, HIGH-5)
- complete_stale_batches: safety net for batches whose completion hook was lost (consumer crash) and intentionally-empty batches - Called from the sweep loop alongside existing sweeps - prune_old_batches called from the prune loop after prune_terminal_jobs - cutoff is max of all per-status retentions - Re-exported from leader.py
- test_batch_abort: 10 jobs, AbortBatchAfter(3), serialized dispatch, bounded [3,4] failed assertion, rest cancelled, batch row aborted - test_batch_finalizer: finalizer enqueued atomically with batch, runs after children, total==5 (finalizer excluded from count) - test_batch_empty_safety: wrong batch_id raises EmptyBatchError, expect_at_least not met raises EmptyBatchError - test_batch_discovery: list_batches active vs complete filtering, consumer hook marks batch complete (not just wait_for_batch) - e2e_worker_serial fixture: TASKQ_MAX_CONCURRENCY=1 (HIGH-1) - batches added to _DELETE_ORDER (HIGH-2) - New actors: batch_abort_worker, batch_finalizer
…types Documentation: - architecture.md: new Batch Subsystem section (batches table, lifecycle, hook, sweeps, wait_for_batch decision table, BatchFilter, streaming) - guides/jobs-clients.md: 6 new sections (failure policies, finalizer, streaming, wait_for_batch params, list_batches, BatchSummary) - _protocol.py: fixed broken docstring references, stray 'and' in CancelPhase Critical bug fixes: - abort_batch: wrap two SQL statements in transaction (CRITICAL — was non-atomic when called via loop_conn, crash between statements could leave batch 'active' with cancelled jobs) - complete_batch: pass loop_conn in cancelled/crashed path (HIGH) - InMemory _abort_batch: add status='active' guard + missing fields (error_message, cancel_requested_at, cancel_phase) to match PG (HIGH) - InMemory enqueue_batch_atomic: add rollback on generator failure (HIGH) Code quality: - Extract _decide_batch_status shared function (DRY — decision table was duplicated between PG and in-memory wait_for_batch) - BatchHandle.job_handles/finalizer_handle: typed as JobHandle, not Any - apply_batch_terminal_outcome: add assert_never for exhaustiveness - Add _metric for stale-batches sweep (dead variable fixed) - Export BatchRow, BatchCounts from __init__ - hasattr guard documented with explanatory comment Test coverage: - 9 direct unit tests for apply_batch_terminal_outcome (each outcome branch) - Hook try/except guard test (batch policy failure isolation) - Generator-failure rollback tests (in-memory + PG integration) - Streaming with both finalizer AND failure_policy test - abort_batch no-row contract test on PG - list_batches combined filter tests - AttemptOutcome 'scheduled' added to backend protocol test
Critical: - C2: retried failures no longer count toward abort — handlers now return 'scheduled' for retries, hook skips non-terminal outcomes - C3: batch row created when failure_policy OR finalizer is set (finalizer-only batches now discoverable via list_batches) High: - H1: BatchFailurePolicy base is now abstract (TypeError on direct instantiation); failure_threshold field on base, read polymorphically - H2: blocking-mode wait_for_batch no longer raises Snooze for aborted batches with in-flight jobs — returns status so poll loop continues - H3: stale-batch sweep catches UndefinedTableError (rolling-deploy safe) - H4: streaming atomic path pairs handles with correct per-item result_adapter and computes was_existing from row/args id - H5: build_enqueue_args strips caller-supplied metadata.batch_id (security boundary — library-injected only) - H6: streaming atomic path no longer materializes the full iterable (chunked contract preserved); max_pending check applied Medium: - M1: max_pending off-by-one fixed (> not >=) - M2: duplicate batch_id raises BatchIdExistsError (typed, not raw PG error) - M3: caller-connection path creates batch row before job inserts - M4: finalizer_job_id uses returned row id (handles idempotency collision) - M5: expected_size>0 with zero jobs raises EmptyBatchError - M6: streaming validation failure wraps in PayloadValidationError with index - M7: hook best-effort semantics documented (crash window, stale sweep) - M8: in-memory create_batch raises on duplicate (parity with PG) - M9: running jobs finish on abort — documented on AbortBatchAfter + BatchAbortedError - M10: increment/reset SQL adds AND status='active' (no post-abort straggler reset) - M11: prune_old_batches skipped when all retentions disabled - M12: CHECK constraints on batches table (failure_threshold>=1, expected_size>=0, consecutive_failures>=0) - M13: BatchHandle docstring states finalizer invariant explicitly - M14: CHANGELOG entries + enqueue_batch docstring updated - M15: abort-wins-over-complete test + strengthened Hypothesis property oracle - M16: PG tests — concurrent increment, rolling-deploy, stale-batch negative, abort cancel columns - M17: e2e abort×finalizer test + discovery test fix + batch B counts asserted - M18: verified e2e gate hook exists (no fix needed) - M19: JobsClient.get_batch + TaskQ.get_batch delegates added Low: - DRY: terminal-status SQL built from TERMINAL_STATUSES (no drift) - DRY: MAX_BATCH_SIZE constant replaces magic 1000 - parse_batch_status() mirrors parse_retry_kind/parse_cancel_phase - BatchStatus type alias added - Docs: finalizer timing, running-job abort semantics, originating_actor, workers.md leader duties, testing.md batch-policy simulation - BatchAbortedError threshold accepts int | None - EmptyBatchError message includes remediation hint - batch.py Provides list updated with BatchSummary + apply_batch_terminal_outcome - test_sync_actor: updated for correct 'scheduled' outcome on retry 4184 tests pass, 0 failures. ruff + pyright clean.
rcbevans
force-pushed
the
spec/batch-subsystem
branch
from
July 30, 2026 04:55
d39f9c6 to
28c665b
Compare
rcbevans
force-pushed
the
spec/batch-subsystem
branch
from
July 30, 2026 07:04
b56b437 to
25b9ccf
Compare
…gather anti-pattern Two changes to fix flaky e2e batch tests: 1. snooze_interval=2s in e2e finalizer actors (batch_finalizer, batch_abort_finalizer): The e2e worker has 2s sweep intervals, so a 10s default snooze creates an unnecessary 8s gap per cycle. Using 2s aligns the finalizer's poll frequency with the test environment. 2. Exclude finalizer handle from asyncio.gather in test_finalizer_snoozes_then_runs and test_batch_abort_with_finalizer: The finalizer snoozes (via wait_for_batch) until all children reach terminal status, so h.wait(timeout=60) on the finalizer ALWAYS times out. With return_exceptions=True the TimeoutError is silently swallowed, wasting the full 60s before the test even starts looking for effects or polling job status. The fix filters the finalizer out of the gather and verifies its completion separately via effects polling or job-status polling. Timeouts on subsequent polls are reduced from 60-90s to 30s since wall clock is no longer wasted.
rcbevans
force-pushed
the
spec/batch-subsystem
branch
from
July 30, 2026 16:38
25b9ccf to
4888b10
Compare
…atch_non_terminal connection param
…rsing - Unit tests proving BatchAbortedError catch path works in-memory - Clock skew resilience tests proving snooze/retry is safe with NULL schedule_to_close - Snooze budget invariant test (attempt < max_attempts across snooze cycles) - Fix test_finalizer_snoozes_then_runs: json.loads() on asyncpg JSONB detail field - Enhanced e2e assertion to capture error_class/error_message/attempt on finalizer failure
…ype coercion The _revive_uuids function in taskq/_json.py unilaterally converted UUID-like strings to uuid.UUID objects during deserialization, regardless of what the developer's Pydantic model declared. This violated the principle of least surprise: a field typed would receive a object after a round-trip through PG JSONB, causing pydantic.ValidationError at dispatch time. Root cause: orjson serializes UUID to its canonical string form, but _revive_uuids converted those strings back to UUID on load — before Pydantic's model_validate could enforce the declared field type. Fix: remove _revive_uuids from loads(). Type coercion is now Pydantic's responsibility: - Field typed UUID: model_validate coerces str → UUID - Field typed str: model_validate keeps str as str This fixes the class of bugs where str-typed payload fields received UUID objects from JSONB deserialization. All metadata access sites already handle str/UUID polymorphism via explicit str() or UUID(str()) conversion. Additional defense-in-depth fixes: - RetryClassifier.classify now treats pydantic.ValidationError as non-retryable (Fail with error_class='PayloadValidationError'), matching the existing PayloadValidationError check - decide_after_failure excludes pydantic.ValidationError from reaching the retry_classifier hook - New validate_actor_payload() helper converts pydantic.ValidationError to PayloadValidationError with rich operator-facing diagnostics (actor name, field-level errors, raw payload) - dispatch.py and _consumer.py use validate_actor_payload() instead of raw model_validate() E2e test actors: FinalizerPayload.batch_id and AbortFinalizerPayload .batch_id changed from str to UUID — matches the semantic intent (a batch ID is a UUID) and works correctly with Pydantic coercion.
The cron-tick effect is recorded inside the actor before it returns, so the job may still be 'running' when the effect is visible. Poll for 'succeeded' status with a 10s budget to close the race.
rcbevans
added a commit
that referenced
this pull request
Aug 1, 2026
…tion, style fixes Merges 4 commits from spec/batch-subsystem: - remove _revive_uuids from JSON deserialization (Pydantic owns type coercion) - add batch abort+finalizer regression tests and fix e2e JSONB parsing - refactor: style and architecture improvements in batch subsystem - fix: hardcoded terminal statuses, missing actor registration Conflict resolution: - _consumer.py: removed early validation block (PR #64 artifact), folded validation into try block matching PR #62 architecture - _consumer.py: acquire_for_actor receives job.payload (raw dict), not validated_payload — real RateLimitRegistry validates internally - dispatch.py: positional actor arg matching PR #62 signature - test_consumer.py: updated tests to match new architecture - worker_entry.py: deduplicated batch_abort_finalizer registration - _validation.py: actor param positional (matching call sites)
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
Introduces first-class batch identity via a
batchestable, a consecutive-failure abort policy (AbortBatchAfter), safe empty-batch detection inwait_for_batch, and batch enumeration vialist_batches. Addresses three issues: batch failure abort (#55), empty-batch/finalizer ordering safety (#58), and batch discovery (#59).Issues addressed
wait_for_batchon a wrongbatch_idreturned silently (ambiguous with genuinely-empty batches); finalizer enqueue was not atomic with batch items.batch_idthemselves.Implementation
New
batchestable and migrationsrc/taskq/migrations/01.00.05_01_pre_batches.sql— Creates thebatchestable with columns:id(PK),queue,status(active/complete/aborted, default active),expected_size,consecutive_failures,failure_threshold(NULL = no policy),finalizer_job_id,originating_actor,created_at,completed_at,metadata. Two partial indexes:batches_queue_status_idx(queue, status WHERE active) andbatches_finalizer_idx(finalizer_job_id WHERE NOT NULL). The table is opt-in — only batches with afailure_policyorfinalizercreate a row.Failure policy types (
src/taskq/batch_policy.py)BatchFailurePolicy— frozen abstract base withshould_abort(consecutive_failures: int) -> bool.AbortBatchAfter(consecutive_failures: int)— concrete policy that aborts when the consecutive failure count reaches the threshold. Validates>= 1at construction.New exceptions (
src/taskq/exceptions.py)BatchAbortedError(batch_id, consecutive_failures, threshold)— raised bywait_for_batchwhen the batch row hasstatus='aborted'and all jobs are terminal.EmptyBatchError(batch_id, expected, actual)— raised bywait_for_batchwhenbatch_idmatches zero jobs and nobatchesrow exists, or whenexpect_at_leastis not satisfied.Backend protocol additions (
src/taskq/backend/_protocol.py)Three new data carriers and 10 new
Backendprotocol methods:BatchRow— read-model of abatchesrow.BatchCounts— live job-count aggregate (mirrorsBatchCompletionStatusfields).BatchFilter— filter params forlist_batches(queue, active, batch_id, limit). Deliberately distinct fromJobFilterto avoid silently ignoring job-oriented fields.Protocol methods:
enqueue_batch_atomic,create_batch,increment_batch_failures,reset_batch_failures,abort_batch,complete_batch,get_batch,list_batches,count_batch_non_terminal,prune_old_batches.PostgresBackend (
src/taskq/backend/_batch_sql.py,src/taskq/backend/postgres.py)_batch_sql.pyholds all batch SQL as module-level functions taking(conn, BatchSql, ...):increment_batch_failures/reset_batch_failures— single CTE statement that updates the counter and counts non-terminal jobs in one round-trip, returning(new_count, threshold, remaining).abort_batch—UPDATE jobs SET status='cancelled' ... WHERE metadata @> $1 AND status IN ('pending','scheduled')plusUPDATE batches SET status='aborted'. Setscancel_requested_atandcancel_phase=2on cancelled rows to match the normal cancel path for observability.list_batches— one query:batchesLEFT JOIN LATERALGROUP BY statusaggregation overjobsmatched bymetadata @>. Returns(BatchRow, BatchCounts)pairs ordered bycreated_at DESC.enqueue_batch_atomic— acquires a connection, opens a transaction, consumes the (lazy) args iterable inchunk_sizegroups, inserts the batches row and finalizer as the LAST statements, commits. Rolls back on any exception.prune_old_batches—DELETE FROM batches WHERE completed_at < cutoff AND NOT EXISTS (matching jobs).PostgresBackendadds thin wrapper methods delegating to these functions.InMemoryBackend (
src/taskq/testing/_batch.py,src/taskq/testing/in_memory.py)New
_batch.pycompanion module implements all 10 batch operations as module-level functions overInMemoryBackend._batchesand_jobs.abort_batchwith no batch row still cancels matching jobs but does not create a row (matching the protocol contract).Post-terminal-write hook (
src/taskq/batch.py)apply_batch_terminal_outcome(backend, job, outcome, *, loop_conn=None)— invoked after every terminal write byworker/dispatch.py(production consumer) andtesting/_runner.py(in-memory runner), so abort and completion semantics are identical on both backends:metadata.batch_id): returns immediately, zero overhead.succeeded: resetsconsecutive_failuresto 0; if remaining non-terminal count hits 0, marks batch complete.failed: incrementsconsecutive_failures; if threshold reached, aborts (cancels remaining pending/scheduled, sets batch aborted); if not aborted and remaining hits 0, marks complete.cancelled/crashed: counts non-terminal jobs; if 0, marks complete.The hook is wrapped in try/except at both call sites so a batch-policy failure never affects the job's own terminal state.
Modified
enqueue_batch(src/taskq/client/_jobs.py)failure_policy: BatchFailurePolicy | None,finalizer: EnqueueItem | None.failure_policyorfinalizeris set and no caller connection: delegates toBackend.enqueue_batch_atomicfor single-transaction atomicity (closes the partial-batch crash window from Fan-out-then-finalize: an empty batch reports complete, and finalizer ordering is load-bearing #58).create_batchand finalizer viaenqueue_with_conn, all on the caller's transaction.metadata.batch_id— this preventswait_for_batchinside the finalizer from counting the finalizer itself as a pending job (infinite Snooze deadlock). Correlation is viabatches.finalizer_job_id.BatchHandle.finalizer_handleis set; the finalizer is also appended as the last entry ofjob_handlesfor backward compat.New
enqueue_batch_streaming(src/taskq/client/_jobs.py)Accepts any
Iterable[EnqueueItem](including generators), chunks internally atchunk_size(1–1000), validates payloads on the fly. Same transaction semantics asenqueue_batch: autonomous atomic path viaenqueue_batch_atomicwhen extras are set and no connection; chunkedenqueue_batchcalls on a caller-owned connection otherwise. Empty iterable raisesValueError.Modified
wait_for_batch(src/taskq/batch.py,src/taskq/testing/_runner.py)New params:
expect_at_least: int | None,on_empty: Literal["error", "ok"],exclude_job_id: UUID | None.Decision table (first match wins, applied when
pending == 0):status='aborted'→ raiseBatchAbortedError.expect_at_leastset andtotal < expect_at_least→ raiseEmptyBatchError.total == 0with a batch row → return status (row proves deliberate creation).total == 0without a batch row,on_empty="error"→ raiseEmptyBatchError.total == 0without a batch row,on_empty="ok"→ return status (old behavior).exclude_job_idomits a specific job from the count query (AND id <> $n, folded into the existing GROUP BY). When not set and a batch row hasfinalizer_job_id, the finalizer is excluded automatically.The in-memory
wait_for_batchin_runner.pymirrors the same decision table, readingbackend._batchesinstead of querying PG.list_batches(src/taskq/client/_jobs.py,src/taskq/batch.py)BatchSummarydataclass (inbatch.py) pairs aBatchRowwith a liveBatchCompletionStatus.JobsClient.list_batches(BatchFilter)delegates toBackend.list_batchesand maps each(BatchRow, BatchCounts)pair toBatchSummary. Returns batches ordered bycreated_at DESC.TaskQ facade (
src/taskq/client/_taskq.py)TaskQ.enqueue_batchforwardsfailure_policyandfinalizer. NewTaskQ.enqueue_batch_streamingandTaskQ.list_batchesdelegates.Leader sweeps (
src/taskq/worker/_leader_sweeps.py,src/taskq/worker/_leader_shared.py)complete_stale_batches(conn, schema)— safety net: marksactivebatches with zero non-terminal jobs ascomplete. Runs in the sweep loop.prune_old_batches— called in the prune loop after terminal jobs are archived, using the maximum of all per-status retention cutoffs. A batch is only deleted after all its jobs have been archived.Re-exports (
src/taskq/__init__.py)Added:
AbortBatchAfter,BatchAbortedError,BatchFailurePolicy,BatchFilter,BatchSummary,EmptyBatchError,apply_batch_terminal_outcome.Breaking changes
wait_for_batchon a wrong/emptybatch_idnow raisesEmptyBatchErrorby default (on_empty="error"). Previously it returnedBatchCompletionStatus(total=0, is_complete=True)silently. Callers that intentionally wait on possibly-empty old-style batches can passon_empty="ok"to restore the old behavior.Backendprotocol gains 10 new methods. Third-party backend implementations must implement these to satisfy the protocol.BACKEND_PROTOCOL_VERSIONis not bumped — changes are additive and collected for the 1.0.0 release.Test coverage
Unit (no PG required)
tests/test_batch_policy.pyBatchFailurePolicy/AbortBatchAfterconstruction, validation,should_abortlogic, frozen dataclasstests/test_batch_exceptions.pyBatchAbortedError,EmptyBatchError—isinstance, message content, carried fieldstests/test_batch_protocol.pyBatchRow,BatchCounts,BatchFilter— frozen dataclass, construction, validationtests/test_in_memory_batch.pytests/test_batch_enqueue.pyenqueue_batchwithfailure_policy(creates batch row) and without (no row), finalizer not stamped withbatch_id, finalizer_job_id set on batch rowtests/test_batch_abort_integration.pyrun_until_drained: threshold triggers abort + cancellation, success resets counter, no-abort without policy, batch marked complete on all-terminaltests/test_batch_streaming.pytests/test_batch_discovery.pylist_batcheswith queue/active filters, empty result, filtering by queuetests/test_batch_prune.pyprune_old_batches(completed batch pruned after retention, active not pruned, batch with remaining jobs not pruned);complete_stale_batchesintegration against PGtests/test_taskq_facade_batch.pyfailure_policy/finalizer,enqueue_batch_streaming,list_batchestests/test_wait_for_batch.pyexpect_at_leastsatisfied/not-satisfied, empty batch with row (expected_size=0) OK, empty without row raises by default / ok withon_empty="ok", aborted batch raisesBatchAbortedError,exclude_job_idomits caller, finalizer auto-excluded, abort property invariant (Hypothesis)PG integration
tests/test_batch_pg.pycreate_batch/get_batch,increment_batch_failuresreturns threshold + remaining,reset_batch_failures,abort_batchcancels jobs + marks batch aborted + setscancel_requested_at/cancel_phase,complete_batch,count_batch_non_terminal,list_batcheswith filters,prune_old_batchestests/test_migrations_batches.pyE2E (full worker + PG)
tests/e2e/test_batch_abort.pyAbortBatchAfter(3)— exactly 3 fail, 7 cancelled, batch abortedtests/e2e/test_batch_finalizer.pyenqueue_batch— finalizer snoozes viawait_for_batchuntil children complete, then runstests/e2e/test_batch_discovery.pylist_batchesfinds active and completed batches with live countstests/e2e/test_batch_empty_safety.pybatch_idraisesEmptyBatchError;expect_at_least=5with 3 jobs raisesEmptyBatchErrorMigration
New forward-only migration:
src/taskq/migrations/01.00.05_01_pre_batches.sql. Creates thebatchestable and two partial indexes. Nopostcounterpart needed.wait_for_batchcatchesUndefinedTableErroron the batches-row fetch so job counting keeps working during the rolling-deploy window before the migration is applied.Closes #55, closes #58, closes #59