fix(downloads): E11.5 epic-end review round 2

Pause now re-reads the job under the same asset-then-job row lock as resume,
cancel, delete and redownload. Its unlocked write waited for a concurrent
cancel's lock and then set the released job back to paused, a holding status,
so a later delete released the same hold again and could free a file another
user's job still held (measured: the overlapping-request test ends 'paused'
instead of 'canceled' with the old read). The lock test now calls the route
handlers, so it also pins which helper each route uses.

Comments follow the code: resolve_url's contract names both verdicts and that a
failed page read is raised as itself; the inherited-failure diagnosis asks the
cookie gate as it stands when the job is claimed, so its advice fits the cookie
that job's Retry would use; the undecryptable-cookie warning no longer claims a
download goes ahead, since the worker parent reaches it only to diagnose.
This commit is contained in:
2026-09-14 01:42:57 +02:00
parent e887233210
commit 2a9ddb78ac
6 changed files with 56 additions and 27 deletions
+6 -5
View File
@@ -174,10 +174,11 @@ def _own_job(db: Session, user: User, job_id: int) -> DownloadJob:
def _own_job_for_update(db: Session, user: User, job_id: int) -> DownloadJob:
"""`_own_job` for a transition that moves its asset's ref count (resume, cancel, delete, redownload).
The job is re-read under a row lock, so two overlapping requests for one job run one after the other
and the second decides on the first's committed result — never both releasing the same hold, which
can free a file another job still holds.
"""`_own_job` for a transition that moves its asset's ref count (resume, cancel, delete, redownload)
or writes the status those decide on (pause). The job is re-read under a row lock, so two
overlapping requests for one job run one after the other and the second decides on the first's
committed result — never both releasing the same hold, and never a pause turning a job a cancel
just released back into a holder; either can free a file another job still holds.
The asset is locked BEFORE the job: the order a finishing worker child takes them in
(`service.finalize_if_running`), so the two can never deadlock."""
@@ -631,7 +632,7 @@ def update_download_meta(
def pause_download(
job_id: int, user: User = Depends(require_human), db: Session = Depends(get_db)
) -> dict:
job = _own_job(db, user, job_id)
job = _own_job_for_update(db, user, job_id)
service.pause(db, job)
return _serialize_job(db, job)