fix(files): review round 1, the server — the tray's window, the GC's locks, a file's origin, the upgrade path
- The queue keeps a done job by when it became done (ready_at), not by its last write: renaming, keeping or moving an old file, or the GC's expiry warning, no longer brings it back as just finished. - GC expiry and eviction re-read each file under the asset→job locks the user routes take, and commit one file at a time: a delete, a re-download or a keep made while the GC runs is honoured, never a second release that frees a file another user still holds. - A file records the origin it was placed under (download_jobs.source_key, migration 0105): an edit whose parent expired still goes back to its folder, not to Uploads. - A folder a file is being put in is held (FOR SHARE) until it lands; a delete of it waits and takes that file home too, instead of failing on the foreign key. - The hash route reads at most two files at once (429 past that) and holds no connection meanwhile. - 0104's backfill dates old files by their request date only: no notice prod wrote names its job. - The old DOWNLOAD_ROOT still finds the files; a files root that is not a mounted volume stops the worker and is logged by the API; the volume-copy recipe looks the project-prefixed names up. - Stale comments about the /downloads mount and the Downloads page.
This commit is contained in:
@@ -15,6 +15,7 @@ from app import sysconfig
|
||||
from app.config import settings
|
||||
from app.downloads import folders, formats, service
|
||||
from app.downloads import gc as gcmod
|
||||
from app.db import SessionLocal
|
||||
from app.downloads.status import JobStatus
|
||||
from app.models import DownloadJob, FileFolder, MediaAsset, Notification
|
||||
|
||||
@@ -123,6 +124,68 @@ def test_an_edit_whose_parent_is_gone_follows_the_parents_asset(db, make_user):
|
||||
assert folders.source_key_for(db, edit) == "site:vimeo.com"
|
||||
|
||||
|
||||
def test_an_edit_keeps_its_origin_after_its_parent_and_its_file_are_gone(db, make_user):
|
||||
# The parent download expires, its file goes with its last holder, and both of the edit's
|
||||
# pointers to it are cleared: the edit must still go back to the YouTube folder it came from.
|
||||
uid = make_user().id
|
||||
parent = _job(db, uid, _asset(db))
|
||||
edit = _job(
|
||||
db, uid, _asset(db, "edit", "e1"), kind="edit", source_job_id=parent.id,
|
||||
source_asset_id=parent.asset_id,
|
||||
) # fmt: skip
|
||||
home = _folder(db, edit)
|
||||
own = FileFolder(user_id=uid, kind=folders.FolderKind.OWN, name="Mine")
|
||||
db.add(own)
|
||||
db.flush()
|
||||
folders.move(db, edit, own)
|
||||
edit.source_job_id = edit.source_asset_id = None # what ON DELETE SET NULL leaves
|
||||
db.delete(parent)
|
||||
db.commit()
|
||||
folders.move(db, edit, home)
|
||||
assert edit.folder_id == home.id
|
||||
folders.move(db, edit, own)
|
||||
folders.delete_own(db, own)
|
||||
assert edit.folder_id == home.id
|
||||
|
||||
|
||||
def test_a_folder_deleted_while_a_file_goes_in_takes_that_file_home_too(db, make_user):
|
||||
# Before, the delete missed the file still on its way in and failed on the foreign key (a 500),
|
||||
# or the file's insert did after the folder went.
|
||||
import threading
|
||||
|
||||
uid = make_user().id
|
||||
own = FileFolder(user_id=uid, kind=folders.FolderKind.OWN, name="Mine")
|
||||
db.add(own)
|
||||
db.commit()
|
||||
own_id = own.id
|
||||
job = _job(db, uid, _asset(db))
|
||||
home = job.folder_id
|
||||
target = folders.own_folder(db, uid, own_id) # held from the check until this commits
|
||||
outcome: list[object] = []
|
||||
|
||||
def delete_it():
|
||||
other = SessionLocal()
|
||||
try:
|
||||
outcome.append(folders.delete_own(other, other.get(FileFolder, own_id)))
|
||||
other.commit()
|
||||
except Exception as e: # noqa: BLE001 — the assertion below names what went wrong
|
||||
outcome.append(e)
|
||||
finally:
|
||||
other.close()
|
||||
|
||||
t = threading.Thread(target=delete_it)
|
||||
t.start()
|
||||
t.join(timeout=1)
|
||||
assert t.is_alive() # the delete waits for the file, even before it is written
|
||||
folders.move(db, job, target)
|
||||
db.commit()
|
||||
t.join(timeout=10)
|
||||
assert outcome == [1]
|
||||
db.expire_all()
|
||||
assert db.get(DownloadJob, job.id).folder_id == home
|
||||
assert db.get(FileFolder, own_id) is None
|
||||
|
||||
|
||||
def test_folders_are_per_user(db, make_user):
|
||||
a, b = make_user().id, make_user().id
|
||||
asset = _asset(db)
|
||||
@@ -201,6 +264,30 @@ def test_the_gc_warns_once_then_takes_the_file_out_of_the_library(db, make_user,
|
||||
assert db.execute(select(func.count()).select_from(MediaAsset)).scalar() == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize("restart", ["unkeep", "move", "retention"])
|
||||
def test_a_file_whose_clock_starts_again_is_warned_again(db, make_user, _root, restart):
|
||||
# The GC warns once per expiry; a file whose expiry was moved must be warned before the new one.
|
||||
(_root / "a.mp4").write_bytes(b"x")
|
||||
uid = make_user().id
|
||||
job = _job(db, uid, _asset(db), expires_at=NOW + timedelta(hours=12))
|
||||
_gc(db)
|
||||
assert job.expiry_warned
|
||||
if restart == "unkeep":
|
||||
folders.set_kept(db, job, True)
|
||||
folders.set_kept(db, job, False)
|
||||
elif restart == "move":
|
||||
home = _folder(db, job)
|
||||
own = FileFolder(user_id=uid, kind=folders.FolderKind.OWN, name="Mine")
|
||||
db.add(own)
|
||||
db.flush()
|
||||
folders.move(db, job, own)
|
||||
folders.move(db, job, home)
|
||||
else:
|
||||
folders.set_retention(db, _folder(db, job), 90)
|
||||
db.commit()
|
||||
assert not job.expiry_warned
|
||||
|
||||
|
||||
def test_the_gc_never_takes_a_kept_file(db, make_user, _root):
|
||||
(_root / "a.mp4").write_bytes(b"x")
|
||||
uid = make_user().id
|
||||
@@ -221,6 +308,52 @@ def test_one_users_expiry_leaves_another_users_copy(db, make_user, _root):
|
||||
assert (_root / "a.mp4").exists()
|
||||
|
||||
|
||||
def _meanwhile(monkeypatch, act):
|
||||
"""Run `act` in another session (another request) between the GC's expiry SELECT and its
|
||||
handling of each file it found — the window a user's click can land in."""
|
||||
real = gcmod._relock_expired
|
||||
|
||||
def relock(db, job_id, asset_id, now):
|
||||
other = SessionLocal()
|
||||
try:
|
||||
act(other, job_id)
|
||||
other.commit()
|
||||
finally:
|
||||
other.close()
|
||||
return real(db, job_id, asset_id, now)
|
||||
|
||||
monkeypatch.setattr(gcmod, "_relock_expired", relock)
|
||||
|
||||
|
||||
def test_a_file_kept_while_the_gc_runs_stays(db, make_user, _root, monkeypatch):
|
||||
(_root / "a.mp4").write_bytes(b"x")
|
||||
job = _job(db, make_user().id, _asset(db), expires_at=NOW - timedelta(minutes=1))
|
||||
_meanwhile(monkeypatch, lambda other, jid: setattr(other.get(DownloadJob, jid), "kept", True))
|
||||
assert _gc(db)["expired"] == 0
|
||||
db.expire_all()
|
||||
assert db.get(DownloadJob, job.id) is not None
|
||||
assert (_root / "a.mp4").exists()
|
||||
|
||||
|
||||
def test_a_file_deleted_while_the_gc_runs_is_released_once(db, make_user, _root, monkeypatch):
|
||||
# Released twice, the shared file would go from under the other user's copy.
|
||||
(_root / "a.mp4").write_bytes(b"x")
|
||||
a, b = make_user().id, make_user().id
|
||||
asset = _asset(db)
|
||||
_job(db, a, asset, expires_at=NOW - timedelta(minutes=1))
|
||||
stays = _job(db, b, asset, expires_at=NOW + timedelta(days=20))
|
||||
|
||||
def user_deletes(other, jid):
|
||||
service.delete(other, other.get(DownloadJob, jid))
|
||||
|
||||
_meanwhile(monkeypatch, user_deletes)
|
||||
_gc(db)
|
||||
db.expire_all()
|
||||
assert db.get(DownloadJob, stays.id).asset_id == asset.id
|
||||
assert db.get(MediaAsset, asset.id).ref_count == 1
|
||||
assert (_root / "a.mp4").exists()
|
||||
|
||||
|
||||
def test_the_cache_cap_never_evicts_a_kept_file_and_an_evicted_one_leaves_every_library(
|
||||
db, make_user, _root
|
||||
):
|
||||
|
||||
Reference in New Issue
Block a user