refactor(downloads): route worker terminal writes through an atomic finalize_if_running (B-sub-1 review)
Follow-up to the /code-review high on the S1 branch. The per-site `if job.status == 'running'` guards were a non-atomic read-then-write (TOCTOU): a cancel committing between the child's status read and its commit could still be overwritten with done/error, re-opening the double-release window. Introduce service.finalize_if_running(db, job_id, **fields): one atomic `UPDATE download_jobs SET ... WHERE status='running'` that returns whether it fired. It flushes pending asset writes first so the asset-then-job row-lock order matches the cancel route (no deadlock), and runs in the caller's session so it commits with the asset writes in one transaction. All five worker sites (download/edit completion, both error paths, cache finish) now go through it, so the invariant lives in one place. The 'job done' logs move inside the finalized branch so a job stopped mid-completion no longer logs a false 'done'. Add tests/test_downloads_finalize.py: running -> finalizes; canceled/paused -> no-op.
This commit is contained in:
@@ -8,7 +8,7 @@ the worker live in `worker.py` / the routes; this module owns enqueue + asset bo
|
|||||||
"""
|
"""
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select, text
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
@@ -16,7 +16,7 @@ from app.config import settings
|
|||||||
from app.downloads import adapters
|
from app.downloads import adapters
|
||||||
from app.downloads import edit as editmod
|
from app.downloads import edit as editmod
|
||||||
from app.downloads import formats, quota, storage
|
from app.downloads import formats, quota, storage
|
||||||
from app.downloads.status import HOLDING
|
from app.downloads.status import HOLDING, JobStatus
|
||||||
from app.models import Channel, DownloadJob, MediaAsset, Video
|
from app.models import Channel, DownloadJob, MediaAsset, Video
|
||||||
|
|
||||||
|
|
||||||
@@ -224,6 +224,33 @@ def enqueue_edit(
|
|||||||
return job
|
return job
|
||||||
|
|
||||||
|
|
||||||
|
def finalize_if_running(db: Session, job_id: int, **fields) -> bool:
|
||||||
|
"""Write terminal `fields` to a job in ONE atomic ``UPDATE ... WHERE status='running'`` and report
|
||||||
|
whether it fired. This is the single owner of the invariant "a finishing worker child must never
|
||||||
|
resurrect a job a route just stopped": the atomic WHERE closes the read-then-write race an
|
||||||
|
``if job.status == 'running'`` guard leaves open, so a concurrent cancel/pause can no longer be
|
||||||
|
overwritten with done/error (which would re-open the double-release → file-delete-under-live-holder
|
||||||
|
class, since `done`/`error` hold the asset but `canceled` does not — see release_asset).
|
||||||
|
|
||||||
|
Runs in the CALLER's session and does NOT commit — the caller commits it together with its asset
|
||||||
|
writes in one transaction. It flushes first so any pending asset write takes its row lock BEFORE
|
||||||
|
this takes the job's, matching the cancel route's asset-then-job order (no deadlock). `fields` keys
|
||||||
|
are internal column names (never user input). Returns True when the row was still running and got
|
||||||
|
updated, False when a route had already moved it out of 'running'."""
|
||||||
|
if not fields:
|
||||||
|
return False
|
||||||
|
db.flush() # asset-then-job lock order: push pending asset writes before we lock the job row
|
||||||
|
# StrEnum members carry through psycopg as strings, but coerce explicitly so a bound status value
|
||||||
|
# is always the plain literal the column stores.
|
||||||
|
params = {k: (v.value if isinstance(v, JobStatus) else v) for k, v in fields.items()}
|
||||||
|
assignments = ", ".join(f"{col} = :{col}" for col in fields)
|
||||||
|
result = db.execute(
|
||||||
|
text(f"UPDATE download_jobs SET {assignments} WHERE id = :_job_id AND status = :_running"),
|
||||||
|
{**params, "_job_id": job_id, "_running": JobStatus.RUNNING.value},
|
||||||
|
)
|
||||||
|
return result.rowcount > 0
|
||||||
|
|
||||||
|
|
||||||
def release_asset(db: Session, job: DownloadJob) -> None:
|
def release_asset(db: Session, job: DownloadJob) -> None:
|
||||||
"""Drop this job's hold on its asset when it leaves a holding state. Once NO job holds the
|
"""Drop this job's hold on its asset when it leaves a holding state. Once NO job holds the
|
||||||
asset anymore, delete the file + row immediately — a deleted download should free its disk,
|
asset anymore, delete the file + row immediately — a deleted download should free its disk,
|
||||||
|
|||||||
+31
-45
@@ -482,17 +482,15 @@ def _finish_from_cache(job_id: int, asset_id: int) -> None:
|
|||||||
with SessionLocal() as db:
|
with SessionLocal() as db:
|
||||||
job = db.get(DownloadJob, job_id)
|
job = db.get(DownloadJob, job_id)
|
||||||
asset = db.get(MediaAsset, asset_id)
|
asset = db.get(MediaAsset, asset_id)
|
||||||
# Only finalize a job the route hasn't stopped: the flip is conditional on the row still being
|
if job and asset:
|
||||||
# RUNNING so a concurrent cancel/pause is never resurrected to `done` (which would break the
|
fields = {"status": JobStatus.DONE, "progress": 100, "phase": None}
|
||||||
# no-double-release invariant in service.release_asset — see downloads/status.py).
|
|
||||||
if job and asset and job.status == JobStatus.RUNNING:
|
|
||||||
job.status = JobStatus.DONE
|
|
||||||
job.progress = 100
|
|
||||||
job.phase = None
|
|
||||||
if not job.display_name:
|
if not job.display_name:
|
||||||
# display_name is VARCHAR(255); a title can be longer (e.g. a recipe in the title).
|
# display_name is VARCHAR(255); a title can be longer (e.g. a recipe in the title).
|
||||||
job.display_name = asset.title[:255] if asset.title else None
|
fields["display_name"] = asset.title[:255] if asset.title else None
|
||||||
asset.last_access_at = datetime.now(timezone.utc)
|
asset.last_access_at = datetime.now(timezone.utc)
|
||||||
|
# Atomic finalize: only marks the job done while it is still running, so a concurrent
|
||||||
|
# cancel/pause is never resurrected (see service.finalize_if_running).
|
||||||
|
service.finalize_if_running(db, job_id, **fields)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
@@ -872,25 +870,20 @@ def _download(job_id: int, asset_id: int, source_kind: str, source_ref: str, spe
|
|||||||
asset.last_access_at = datetime.now(timezone.utc)
|
asset.last_access_at = datetime.now(timezone.utc)
|
||||||
asset.expires_at = expires
|
asset.expires_at = expires
|
||||||
job = db.get(DownloadJob, job_id)
|
job = db.get(DownloadJob, job_id)
|
||||||
# Conditional terminal write: only finalize while the row is still RUNNING, so a child that
|
|
||||||
# finishes AFTER a route cancel/pause (narrow window past the cooperative checkpoints) can't
|
|
||||||
# overwrite `canceled`/`paused` with `done` and re-open the double-release class fixed here.
|
|
||||||
finalized = bool(job and job.status == JobStatus.RUNNING)
|
|
||||||
if finalized:
|
|
||||||
job.status = JobStatus.DONE
|
|
||||||
job.progress = 100
|
|
||||||
job.phase = None
|
|
||||||
job.speed_bps = None
|
|
||||||
job.eta_s = None
|
|
||||||
if not job.display_name:
|
|
||||||
# display_name is VARCHAR(255) — clip a long title (see _fill_asset_meta_early).
|
|
||||||
job.display_name = meta.title[:255] if meta.title else None
|
|
||||||
# Capture the owner before commit expires the row (avoids a refresh SELECT just to notify).
|
# Capture the owner before commit expires the row (avoids a refresh SELECT just to notify).
|
||||||
owner_id = job.user_id if job else None
|
owner_id = job.user_id if job else None
|
||||||
|
fields = {"status": JobStatus.DONE, "progress": 100, "phase": None, "speed_bps": None,
|
||||||
|
"eta_s": None}
|
||||||
|
if job and not job.display_name and meta.title:
|
||||||
|
# display_name is VARCHAR(255) — clip a long title (see _fill_asset_meta_early).
|
||||||
|
fields["display_name"] = meta.title[:255]
|
||||||
|
# Atomic terminal write: finalizes ONLY while the row is still running, so a child that
|
||||||
|
# finishes just after a route cancel/pause can't resurrect it (see finalize_if_running).
|
||||||
|
finalized = service.finalize_if_running(db, job_id, **fields)
|
||||||
db.commit()
|
db.commit()
|
||||||
if finalized:
|
if finalized:
|
||||||
_notify_download_done(db, owner_id, meta.title, job_id, kind="download")
|
_notify_download_done(db, owner_id, meta.title, job_id, kind="download")
|
||||||
log.info("job %s done: %s", job_id, rel)
|
log.info("job %s done: %s", job_id, rel)
|
||||||
|
|
||||||
except (_Aborted, editmod.EditAborted):
|
except (_Aborted, editmod.EditAborted):
|
||||||
# Paused/canceled mid-download or mid-transcode: free the asset for a later retry; keep the
|
# Paused/canceled mid-download or mid-transcode: free the asset for a later retry; keep the
|
||||||
@@ -904,14 +897,12 @@ def _download(job_id: int, asset_id: int, source_kind: str, source_ref: str, spe
|
|||||||
if asset:
|
if asset:
|
||||||
asset.status = "error"
|
asset.status = "error"
|
||||||
asset.error = msg
|
asset.error = msg
|
||||||
job = db.get(DownloadJob, job_id)
|
# Atomic fail: only while still running, so a route-set cancel/pause is never overwritten
|
||||||
# Don't overwrite a route-set cancel/pause with `error` (error also holds the asset, so it
|
# with `error` (error also holds the asset → would re-open the double-release class).
|
||||||
# would re-open the double-release class): only fail a job that is still RUNNING.
|
service.finalize_if_running(
|
||||||
if job and job.status == JobStatus.RUNNING:
|
db, job_id, status=JobStatus.ERROR, error=msg,
|
||||||
job.status = JobStatus.ERROR
|
error_code=dlerrors.classify(msg), phase=None,
|
||||||
job.error = msg
|
)
|
||||||
job.error_code = dlerrors.classify(msg)
|
|
||||||
job.phase = None
|
|
||||||
db.commit()
|
db.commit()
|
||||||
log.warning("job %s failed: %s", job_id, msg)
|
log.warning("job %s failed: %s", job_id, msg)
|
||||||
finally:
|
finally:
|
||||||
@@ -938,14 +929,11 @@ def _fail_edit(job_id: int, asset_id: int, msg: str) -> None:
|
|||||||
if asset:
|
if asset:
|
||||||
asset.status = "error"
|
asset.status = "error"
|
||||||
asset.error = msg
|
asset.error = msg
|
||||||
job = db.get(DownloadJob, job_id)
|
# Atomic fail: only while still running (see the download error path).
|
||||||
# Only fail a job that is still RUNNING — never overwrite a route-set cancel/pause (see the
|
service.finalize_if_running(
|
||||||
# download error path).
|
db, job_id, status=JobStatus.ERROR, error=msg,
|
||||||
if job and job.status == JobStatus.RUNNING:
|
error_code=dlerrors.classify(msg), phase=None,
|
||||||
job.status = JobStatus.ERROR
|
)
|
||||||
job.error = msg
|
|
||||||
job.error_code = dlerrors.classify(msg)
|
|
||||||
job.phase = None
|
|
||||||
db.commit()
|
db.commit()
|
||||||
log.warning("edit job %s failed: %s", job_id, msg)
|
log.warning("edit job %s failed: %s", job_id, msg)
|
||||||
|
|
||||||
@@ -1021,19 +1009,17 @@ def _process_edit(job_id: int, asset_id: int) -> None:
|
|||||||
asset.last_access_at = datetime.now(timezone.utc)
|
asset.last_access_at = datetime.now(timezone.utc)
|
||||||
asset.expires_at = expires
|
asset.expires_at = expires
|
||||||
job = db.get(DownloadJob, job_id)
|
job = db.get(DownloadJob, job_id)
|
||||||
# Conditional terminal write (see the download path): don't resurrect a route-stopped job.
|
|
||||||
finalized = bool(job and job.status == JobStatus.RUNNING)
|
|
||||||
if finalized:
|
|
||||||
job.status = JobStatus.DONE
|
|
||||||
job.progress = 100
|
|
||||||
job.phase = None
|
|
||||||
# Capture owner + title before commit expires the rows (avoids a refresh SELECT).
|
# Capture owner + title before commit expires the rows (avoids a refresh SELECT).
|
||||||
owner_id = job.user_id if job else None
|
owner_id = job.user_id if job else None
|
||||||
dl_title = asset.title if asset else None
|
dl_title = asset.title if asset else None
|
||||||
|
# Atomic terminal write (see the download path): don't resurrect a route-stopped job.
|
||||||
|
finalized = service.finalize_if_running(
|
||||||
|
db, job_id, status=JobStatus.DONE, progress=100, phase=None
|
||||||
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
if finalized:
|
if finalized:
|
||||||
_notify_download_done(db, owner_id, dl_title, job_id, kind="edit")
|
_notify_download_done(db, owner_id, dl_title, job_id, kind="edit")
|
||||||
log.info("edit job %s done: %s", job_id, rel)
|
log.info("edit job %s done: %s", job_id, rel)
|
||||||
|
|
||||||
except editmod.EditAborted:
|
except editmod.EditAborted:
|
||||||
# Paused/canceled mid-encode: free the asset for a later retry (ref_count is the route's job).
|
# Paused/canceled mid-encode: free the asset for a later retry (ref_count is the route's job).
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
"""DB-backed regression tests for `service.finalize_if_running` (B-sub-1).
|
||||||
|
|
||||||
|
The worker's terminal writes go through this helper so a child that finishes just after a route
|
||||||
|
cancel/pause can NEVER flip the job back to done/error — which would re-open the double-release ->
|
||||||
|
file-delete-under-live-holder class (done/error hold the asset, canceled does not). The guard is an
|
||||||
|
atomic `UPDATE ... WHERE status='running'`, so these tests assert it fires exactly when the job is
|
||||||
|
still running and is a no-op otherwise. Uses the real-Postgres `db` fixture; skipped without a DB.
|
||||||
|
"""
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
from app.downloads import service
|
||||||
|
from app.downloads.status import JobStatus
|
||||||
|
from app.models import DownloadJob, User
|
||||||
|
|
||||||
|
|
||||||
|
def _running_job(db) -> DownloadJob:
|
||||||
|
user = User(email="dl@example.com")
|
||||||
|
db.add(user)
|
||||||
|
db.flush()
|
||||||
|
job = DownloadJob(
|
||||||
|
user_id=user.id,
|
||||||
|
source_kind="youtube",
|
||||||
|
source_ref="x" * 11,
|
||||||
|
status=JobStatus.RUNNING.value,
|
||||||
|
queue_pos=1,
|
||||||
|
profile_snapshot={},
|
||||||
|
)
|
||||||
|
db.add(job)
|
||||||
|
db.commit()
|
||||||
|
return job
|
||||||
|
|
||||||
|
|
||||||
|
def _row(db, job_id):
|
||||||
|
# Read straight from the DB: SessionLocal uses expire_on_commit=False, so the ORM identity-map
|
||||||
|
# copy is stale after finalize_if_running's raw UPDATE + commit.
|
||||||
|
return db.execute(
|
||||||
|
text("SELECT status, progress FROM download_jobs WHERE id = :id"), {"id": job_id}
|
||||||
|
).one()
|
||||||
|
|
||||||
|
|
||||||
|
def test_finalize_marks_a_running_job_done_and_reports_it(db):
|
||||||
|
job = _running_job(db)
|
||||||
|
won = service.finalize_if_running(db, job.id, status=JobStatus.DONE, progress=100, phase=None)
|
||||||
|
db.commit()
|
||||||
|
assert won is True
|
||||||
|
status, progress = _row(db, job.id)
|
||||||
|
assert status == "done"
|
||||||
|
assert progress == 100
|
||||||
|
|
||||||
|
|
||||||
|
def test_finalize_is_a_noop_on_a_canceled_job(db):
|
||||||
|
# The exact B-sub-1 scenario: a route already canceled the job (released its asset once), and a
|
||||||
|
# finishing child then tries to write `done`. The guard must NOT resurrect it, or a later delete
|
||||||
|
# would double-release the asset.
|
||||||
|
job = _running_job(db)
|
||||||
|
db.execute(
|
||||||
|
text("UPDATE download_jobs SET status = 'canceled' WHERE id = :id"), {"id": job.id}
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
won = service.finalize_if_running(db, job.id, status=JobStatus.DONE, progress=100)
|
||||||
|
db.commit()
|
||||||
|
assert won is False
|
||||||
|
assert _row(db, job.id).status == "canceled"
|
||||||
|
|
||||||
|
|
||||||
|
def test_finalize_is_a_noop_on_a_paused_job(db):
|
||||||
|
job = _running_job(db)
|
||||||
|
db.execute(
|
||||||
|
text("UPDATE download_jobs SET status = 'paused' WHERE id = :id"), {"id": job.id}
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
won = service.finalize_if_running(db, job.id, status=JobStatus.ERROR, error="boom", phase=None)
|
||||||
|
db.commit()
|
||||||
|
assert won is False
|
||||||
|
assert _row(db, job.id).status == "paused"
|
||||||
Reference in New Issue
Block a user