Skip to content

feat(batch): failure policy, empty-batch safety, batch discovery (#55, #58, #59) - #62

Open
rcbevans wants to merge 21 commits into
mainfrom
spec/batch-subsystem
Open

feat(batch): failure policy, empty-batch safety, batch discovery (#55, #58, #59)#62
rcbevans wants to merge 21 commits into
mainfrom
spec/batch-subsystem

Conversation

@rcbevans

@rcbevans rcbevans commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Summary

Introduces first-class batch identity via a batches table, a consecutive-failure abort policy (AbortBatchAfter), safe empty-batch detection in wait_for_batch, and batch enumeration via list_batches. Addresses three issues: batch failure abort (#55), empty-batch/finalizer ordering safety (#58), and batch discovery (#59).

Issues addressed

Implementation

New batches table and migration

src/taskq/migrations/01.00.05_01_pre_batches.sql — Creates the batches table 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) and batches_finalizer_idx (finalizer_job_id WHERE NOT NULL). The table is opt-in — only batches with a failure_policy or finalizer create a row.

Failure policy types (src/taskq/batch_policy.py)

  • BatchFailurePolicy — frozen abstract base with should_abort(consecutive_failures: int) -> bool.
  • AbortBatchAfter(consecutive_failures: int) — concrete policy that aborts when the consecutive failure count reaches the threshold. Validates >= 1 at construction.

New exceptions (src/taskq/exceptions.py)

  • BatchAbortedError(batch_id, consecutive_failures, threshold) — raised by wait_for_batch when the batch row has status='aborted' and all jobs are terminal.
  • EmptyBatchError(batch_id, expected, actual) — raised by wait_for_batch when batch_id matches zero jobs and no batches row exists, or when expect_at_least is not satisfied.

Backend protocol additions (src/taskq/backend/_protocol.py)

Three new data carriers and 10 new Backend protocol methods:

  • BatchRow — read-model of a batches row.
  • BatchCounts — live job-count aggregate (mirrors BatchCompletionStatus fields).
  • BatchFilter — filter params for list_batches (queue, active, batch_id, limit). Deliberately distinct from JobFilter to 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.py holds 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_batchUPDATE jobs SET status='cancelled' ... WHERE metadata @> $1 AND status IN ('pending','scheduled') plus UPDATE batches SET status='aborted'. Sets cancel_requested_at and cancel_phase=2 on cancelled rows to match the normal cancel path for observability.
  • list_batches — one query: batches LEFT JOIN LATERAL GROUP BY status aggregation over jobs matched by metadata @>. Returns (BatchRow, BatchCounts) pairs ordered by created_at DESC.
  • enqueue_batch_atomic — acquires a connection, opens a transaction, consumes the (lazy) args iterable in chunk_size groups, inserts the batches row and finalizer as the LAST statements, commits. Rolls back on any exception.
  • prune_old_batchesDELETE FROM batches WHERE completed_at < cutoff AND NOT EXISTS (matching jobs).

PostgresBackend adds thin wrapper methods delegating to these functions.

InMemoryBackend (src/taskq/testing/_batch.py, src/taskq/testing/in_memory.py)

New _batch.py companion module implements all 10 batch operations as module-level functions over InMemoryBackend._batches and _jobs. abort_batch with 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 by worker/dispatch.py (production consumer) and testing/_runner.py (in-memory runner), so abort and completion semantics are identical on both backends:

  • Non-batched jobs (no metadata.batch_id): returns immediately, zero overhead.
  • succeeded: resets consecutive_failures to 0; if remaining non-terminal count hits 0, marks batch complete.
  • failed: increments consecutive_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)

  • New params: failure_policy: BatchFailurePolicy | None, finalizer: EnqueueItem | None.
  • When failure_policy or finalizer is set and no caller connection: delegates to Backend.enqueue_batch_atomic for 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).
  • When a caller connection is provided: inserts jobs, then create_batch and finalizer via enqueue_with_conn, all on the caller's transaction.
  • The finalizer job is not stamped with metadata.batch_id — this prevents wait_for_batch inside the finalizer from counting the finalizer itself as a pending job (infinite Snooze deadlock). Correlation is via batches.finalizer_job_id.
  • BatchHandle.finalizer_handle is set; the finalizer is also appended as the last entry of job_handles for backward compat.

New enqueue_batch_streaming (src/taskq/client/_jobs.py)

Accepts any Iterable[EnqueueItem] (including generators), chunks internally at chunk_size (1–1000), validates payloads on the fly. Same transaction semantics as enqueue_batch: autonomous atomic path via enqueue_batch_atomic when extras are set and no connection; chunked enqueue_batch calls on a caller-owned connection otherwise. Empty iterable raises ValueError.

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):

  1. Batch row status='aborted' → raise BatchAbortedError.
  2. expect_at_least set and total < expect_at_least → raise EmptyBatchError.
  3. total == 0 with a batch row → return status (row proves deliberate creation).
  4. total == 0 without a batch row, on_empty="error" → raise EmptyBatchError.
  5. total == 0 without a batch row, on_empty="ok" → return status (old behavior).

exclude_job_id omits a specific job from the count query (AND id <> $n, folded into the existing GROUP BY). When not set and a batch row has finalizer_job_id, the finalizer is excluded automatically.

The in-memory wait_for_batch in _runner.py mirrors the same decision table, reading backend._batches instead of querying PG.

list_batches (src/taskq/client/_jobs.py, src/taskq/batch.py)

BatchSummary dataclass (in batch.py) pairs a BatchRow with a live BatchCompletionStatus. JobsClient.list_batches(BatchFilter) delegates to Backend.list_batches and maps each (BatchRow, BatchCounts) pair to BatchSummary. Returns batches ordered by created_at DESC.

TaskQ facade (src/taskq/client/_taskq.py)

TaskQ.enqueue_batch forwards failure_policy and finalizer. New TaskQ.enqueue_batch_streaming and TaskQ.list_batches delegates.

Leader sweeps (src/taskq/worker/_leader_sweeps.py, src/taskq/worker/_leader_shared.py)

  • complete_stale_batches(conn, schema) — safety net: marks active batches with zero non-terminal jobs as complete. 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_batch on a wrong/empty batch_id now raises EmptyBatchError by default (on_empty="error"). Previously it returned BatchCompletionStatus(total=0, is_complete=True) silently. Callers that intentionally wait on possibly-empty old-style batches can pass on_empty="ok" to restore the old behavior.
  • Backend protocol gains 10 new methods. Third-party backend implementations must implement these to satisfy the protocol. BACKEND_PROTOCOL_VERSION is not bumped — changes are additive and collected for the 1.0.0 release.

Test coverage

Unit (no PG required)

File Covers
tests/test_batch_policy.py BatchFailurePolicy / AbortBatchAfter construction, validation, should_abort logic, frozen dataclass
tests/test_batch_exceptions.py BatchAbortedError, EmptyBatchErrorisinstance, message content, carried fields
tests/test_batch_protocol.py BatchRow, BatchCounts, BatchFilter — frozen dataclass, construction, validation
tests/test_in_memory_batch.py All 10 InMemoryBackend batch operations: create, get, increment, reset, abort (with and without batch row), complete, count, list, prune, enqueue_batch_atomic
tests/test_batch_enqueue.py enqueue_batch with failure_policy (creates batch row) and without (no row), finalizer not stamped with batch_id, finalizer_job_id set on batch row
tests/test_batch_abort_integration.py End-to-end abort via run_until_drained: threshold triggers abort + cancellation, success resets counter, no-abort without policy, batch marked complete on all-terminal
tests/test_batch_streaming.py Large/small iterables, failure_policy with streaming, empty iterable raises, invalid chunk_size raises
tests/test_batch_discovery.py list_batches with queue/active filters, empty result, filtering by queue
tests/test_batch_prune.py InMemoryBackend prune_old_batches (completed batch pruned after retention, active not pruned, batch with remaining jobs not pruned); complete_stale_batches integration against PG
tests/test_taskq_facade_batch.py TaskQ facade forwards failure_policy/finalizer, enqueue_batch_streaming, list_batches
tests/test_wait_for_batch.py expect_at_least satisfied/not-satisfied, empty batch with row (expected_size=0) OK, empty without row raises by default / ok with on_empty="ok", aborted batch raises BatchAbortedError, exclude_job_id omits caller, finalizer auto-excluded, abort property invariant (Hypothesis)

PG integration

File Covers
tests/test_batch_pg.py create_batch/get_batch, increment_batch_failures returns threshold + remaining, reset_batch_failures, abort_batch cancels jobs + marks batch aborted + sets cancel_requested_at/cancel_phase, complete_batch, count_batch_non_terminal, list_batches with filters, prune_old_batches
tests/test_migrations_batches.py Batches table columns, defaults, indexes after migration

E2E (full worker + PG)

File Covers
tests/e2e/test_batch_abort.py 10-job all-fail batch with AbortBatchAfter(3) — exactly 3 fail, 7 cancelled, batch aborted
tests/e2e/test_batch_finalizer.py 5 chunk jobs + finalizer in one enqueue_batch — finalizer snoozes via wait_for_batch until children complete, then runs
tests/e2e/test_batch_discovery.py list_batches finds active and completed batches with live counts
tests/e2e/test_batch_empty_safety.py Wrong batch_id raises EmptyBatchError; expect_at_least=5 with 3 jobs raises EmptyBatchError

Migration

New forward-only migration: src/taskq/migrations/01.00.05_01_pre_batches.sql. Creates the batches table and two partial indexes. No post counterpart needed. wait_for_batch catches UndefinedTableError on 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

@rcbevans
rcbevans requested review from XBeg9, clinzy and kjw-azx July 30, 2026 01:35
@rcbevans rcbevans self-assigned this Jul 30, 2026
Base automatically changed from feat/e2e-test-suite to main July 30, 2026 04:32
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
rcbevans force-pushed the spec/batch-subsystem branch from 8ad3d60 to d39f9c6 Compare July 30, 2026 04:34
rcbevans added 13 commits July 29, 2026 21:54
…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
rcbevans force-pushed the spec/batch-subsystem branch from d39f9c6 to 28c665b Compare July 30, 2026 04:55
@rcbevans
rcbevans force-pushed the spec/batch-subsystem branch from b56b437 to 25b9ccf Compare July 30, 2026 07:04
…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
rcbevans force-pushed the spec/batch-subsystem branch from 25b9ccf to 4888b10 Compare July 30, 2026 16:38
rcbevans added 5 commits July 30, 2026 09:55
…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)
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