The code cited its plan instead of saying why: `E28 review J4`, `(R6 S3b)`, `S1 justified…`,
`deep-review round 5`. That plan lives in the private ops repo, so for every reader of this one the
citation was noise, and a sentence whose subject was the plan step ("round 2 showed…") hid its
lesson behind an id. Each one is gone and the "why" kept: a bare citation is deleted, a sentence
carrying a lesson is reworded around it ("a later attempt showed…").
Measured (`scripts/audit_metrics.py`, product code): 652 lines in 270 files carried an epic code by
the definition the next commit holds (507 / 241 by the old, two-digit-only one) -> 0. Across all
code, tests, scripts and configs: ~1100 epic-code lines plus ~320 sprint/round ids.
Behaviour unchanged, checked mechanically: every changed .py file has the same AST once docstrings
are blanked; css/sh/toml/yml/json files are identical once comments are stripped; the only
non-comment changes in TS/JS are test titles, one description string in a contract table, and five
ESLint messages, whose text is all the gates snapshot diff holds. Not touched: Markdown (docs and
the changelog keep their history), Alembic migrations, TV episode notation (`S2 · E5`), DESIGN.md
ids (`C1`, `B4`, `S3h`), tracker ids (`SEC-16`, `DX-17`).
One stale claim fixed on the way: test_hls_reaper's docstring said the player checkpoints "every
10 s whether or not it is playing", which plex/stream.py calls wrong; it now says what the code does.
140 lines
4.8 KiB
Python
140 lines
4.8 KiB
Python
"""Overlapping requests on ONE job — DB-backed, two real sessions.
|
|
|
|
The routes that move a job's asset ref count (resume, cancel, delete, redownload) used to decide on an
|
|
unlocked read: two overlapping requests for one job (a double-click, a second tab, a replay after a
|
|
gateway timeout) both saw it holding its asset and both released the hold. The count then reaches 0
|
|
while another job still holds the asset, and GC or the ready-file delete removes it under that job.
|
|
A pause racing a cancel did the same one step later: its unlocked write turned the released job back
|
|
into a holder, which a later delete releases again. `_own_job_for_update` makes the second request
|
|
wait and decide on the first one's committed result.
|
|
"""
|
|
|
|
import threading
|
|
|
|
import pytest
|
|
from fastapi import HTTPException
|
|
from sqlalchemy import select
|
|
|
|
from app.db import SessionLocal
|
|
from app.downloads import service
|
|
from app.models import DownloadJob, MediaAsset, User
|
|
from app.routes.downloads import (
|
|
_own_job_for_update,
|
|
cancel_download,
|
|
delete_download,
|
|
pause_download,
|
|
)
|
|
|
|
_REF = "https://example.com/video"
|
|
|
|
|
|
def _asset_held_by_two_jobs(db, status="error"):
|
|
mine, theirs = User(email="lock-mine@example.com"), User(email="lock-theirs@example.com")
|
|
db.add_all([mine, theirs])
|
|
db.flush()
|
|
asset = MediaAsset(
|
|
source_kind="url", source_ref=_REF, format_sig="shared", status="error", ref_count=2
|
|
)
|
|
db.add(asset)
|
|
db.flush()
|
|
jobs = [
|
|
DownloadJob(
|
|
user_id=owner.id,
|
|
asset_id=asset.id,
|
|
source_kind="url",
|
|
source_ref=_REF,
|
|
status=status if owner is mine else "error",
|
|
queue_pos=0,
|
|
profile_snapshot={},
|
|
)
|
|
for owner in (mine, theirs)
|
|
]
|
|
db.add_all(jobs)
|
|
db.commit()
|
|
return mine, asset, jobs[0]
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("status", "transition", "route", "left"),
|
|
[
|
|
("error", service.cancel, cancel_download, "canceled"),
|
|
("error", service.delete, delete_download, None),
|
|
("queued", service.cancel, pause_download, "canceled"),
|
|
],
|
|
)
|
|
def test_overlapping_requests_for_one_job_release_its_hold_once(
|
|
db, status, transition, route, left
|
|
):
|
|
user, asset, job = _asset_held_by_two_jobs(db, status)
|
|
job_id = job.id # read before the delete case removes the row under the expired instance
|
|
first, second = SessionLocal(), SessionLocal()
|
|
finished = threading.Event()
|
|
|
|
def the_other_request():
|
|
try:
|
|
route(job_id, user=user, db=second)
|
|
except HTTPException:
|
|
pass # a request after the delete: the job is gone, a 404
|
|
finally:
|
|
second.close()
|
|
finished.set()
|
|
|
|
try:
|
|
locked = _own_job_for_update(first, user, job_id)
|
|
other = threading.Thread(target=the_other_request)
|
|
other.start()
|
|
# The second request waits for the first one's locks instead of deciding on the same row.
|
|
assert not finished.wait(0.5), "the second request did not wait for the first"
|
|
transition(first, locked)
|
|
other.join(10)
|
|
assert finished.is_set()
|
|
finally:
|
|
first.close()
|
|
|
|
db.expire_all()
|
|
mine = select(DownloadJob.status).where(DownloadJob.id == job_id)
|
|
assert db.execute(mine).scalar_one_or_none() == left
|
|
assert db.get(MediaAsset, asset.id).ref_count == 1 # the other user's job still holds it
|
|
|
|
|
|
def test_losing_the_asset_insert_race_keeps_the_callers_own_writes(db, monkeypatch):
|
|
# Retry clears the job's error, THEN may create the cookie asset. When another request inserted that
|
|
# asset first, the loser used to roll the whole session back — and requeue the job with its stale
|
|
# error, which a stall or crash later leaves showing under the new failure.
|
|
user = User(email="race@example.com")
|
|
db.add(user)
|
|
db.flush()
|
|
job = DownloadJob(
|
|
user_id=user.id,
|
|
source_kind="url",
|
|
source_ref=_REF,
|
|
status="error",
|
|
error="the old failure",
|
|
queue_pos=0,
|
|
profile_snapshot={},
|
|
)
|
|
db.add(job)
|
|
db.commit()
|
|
job.error = None # the caller's pending write
|
|
|
|
real_flush, raced = db.flush, []
|
|
|
|
def flush_after_the_other_request_inserted(*args, **kwargs):
|
|
if not raced: # the other request commits the same asset between our lookup and our insert
|
|
raced.append(True)
|
|
with SessionLocal() as other:
|
|
other.add(
|
|
MediaAsset(source_kind="url", source_ref=_REF, format_sig="raced", ref_count=0)
|
|
)
|
|
other.commit()
|
|
return real_flush(*args, **kwargs)
|
|
|
|
monkeypatch.setattr(db, "flush", flush_after_the_other_request_inserted)
|
|
|
|
asset = service.get_or_create_asset(db, "url", _REF, "raced")
|
|
db.commit()
|
|
|
|
assert raced and asset.format_sig == "raced"
|
|
db.expire_all()
|
|
assert db.get(DownloadJob, job.id).error is None
|