Verified by reading bq/processors/processor.py (bq from PyPI, the SQLAlchemy/Postgres task queue by Fang-Pen Lin):
try:
with db.begin_nested() as savepoint:
result = self.func(**base_kwargs, **task.kwargs)
except Exception as exc:
events.task_failure.send(self, task=task, exception=exc)
task.state = models.TaskState.FAILED
...
if isinstance(exc, self.retry_exceptions) and self.retry_policy is not None:
retry_scheduled_at = self.retry_policy(task)
if retry_scheduled_at is not None:
task.state = models.TaskState.PENDING
task.scheduled_at = retry_scheduled_atConsequences that bit me while designing a 'wait for upstream jobs, then give up' writer:
- A processor that wants to re-enqueue another task (e.g. re-run a failed transcription) and then raise a 'come back later' exception for its own retry policy cannot: the
db.add(other_task)is inside the savepoint and is rolled back with it. Same for a push/email task queued before raising. - Writes done by the processor that are meant to survive must happen on the success path (return normally), in a separate
Session(engine)with its ownbegin(), or in a handler connected tobq.events.task_failure, which fires after the rollback onobject_session(task)and is committed together with the task's FAILED/PENDING state. - The retry decision happens after the
task_failuresignal, so a handler that needs to know whether this failure is terminal must recompute the policy's condition itself (e.g.attempts * step >= budget).
The pattern that works: have the upstream job (the one that completes) own the compensating action, or raise a distinct terminal exception from the waiting job and react to it in a task_failure handler.