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:
@@ -421,6 +421,21 @@ def test_a_deleted_file_leaves_the_library_and_the_log_says_so(db, make_user, as
|
||||
assert (row.actor_id, row.target_id) == (alice.id, str(job.id))
|
||||
|
||||
|
||||
def test_a_file_shared_with_someone_is_theirs_to_watch_not_to_change(db, make_user, as_user, _root):
|
||||
# The read routes let a share's recipient in; the write routes must not.
|
||||
alice, friend = make_user(), make_user()
|
||||
job = _file(db, alice.id, title="Mine")
|
||||
db.add(DownloadShare(job_id=job.id, from_user_id=alice.id, to_user_id=friend.id))
|
||||
db.commit()
|
||||
c = as_user(friend)
|
||||
assert c.patch(f"/api/files/{job.id}", json={"name": "Theirs"}).status_code == 404
|
||||
assert c.patch(f"/api/files/{job.id}", json={"kept": True}).status_code == 404
|
||||
assert c.delete(f"/api/files/{job.id}").status_code == 404
|
||||
db.expire_all()
|
||||
still = db.get(DownloadJob, job.id)
|
||||
assert still is not None and still.display_name is None and not still.kept
|
||||
|
||||
|
||||
# --- upload, the file, its hash ------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -532,6 +547,26 @@ def test_the_file_carries_its_hash_once_known_and_the_hash_is_taken_on_first_ask
|
||||
assert as_user(make_user()).get(f"/api/files/{job.id}/sha256").status_code == 404
|
||||
|
||||
|
||||
def test_only_a_few_files_are_hashed_at_once(db, make_user, as_user, _root):
|
||||
# Each hash holds a thread of the pool every sync route shares for the whole read; a caller
|
||||
# past the few that may run is told to come back, not queued behind them.
|
||||
from app.routes import files as filesroute
|
||||
|
||||
alice = make_user()
|
||||
job = _file(db, alice.id, rel="v.mp4")
|
||||
(_root / "v.mp4").write_bytes(b"bytes")
|
||||
held = [filesroute._HASHING.acquire(blocking=False) for _ in range(filesroute._HASHERS)]
|
||||
try:
|
||||
assert all(held)
|
||||
busy = as_user(alice).get(f"/api/files/{job.id}/sha256")
|
||||
finally:
|
||||
for _ in held:
|
||||
filesroute._HASHING.release()
|
||||
assert busy.status_code == 429
|
||||
assert busy.json()["detail"]["reason"] == "file_hash_busy"
|
||||
assert as_user(alice).get(f"/api/files/{job.id}/sha256").status_code == 200
|
||||
|
||||
|
||||
def test_a_new_file_at_an_assets_path_forgets_the_old_hash(db, make_user):
|
||||
job = _file(db, make_user().id)
|
||||
asset = db.get(MediaAsset, job.asset_id)
|
||||
@@ -562,7 +597,7 @@ def test_the_queue_is_what_is_in_flight_and_what_just_finished_not_the_library(
|
||||
db.execute(
|
||||
update(DownloadJob)
|
||||
.where(DownloadJob.id == job.id)
|
||||
.values(updated_at=NOW - timedelta(hours=1))
|
||||
.values(updated_at=NOW - timedelta(hours=1), ready_at=NOW - timedelta(hours=1))
|
||||
)
|
||||
db.commit()
|
||||
rows = as_user(user).get("/api/downloads").json()
|
||||
@@ -570,3 +605,22 @@ def test_the_queue_is_what_is_in_flight_and_what_just_finished_not_the_library(
|
||||
assert {row["id"] for row in rows} == {fresh.id, waiting.id, stuck.id, paused.id, stopped.id}
|
||||
# The tray names where each came from, as the library does.
|
||||
assert {row["source_key"] for row in rows} == {"youtube"}
|
||||
|
||||
|
||||
def test_an_old_file_written_to_does_not_come_back_as_just_finished(db, make_user, as_user):
|
||||
# Renaming, keeping or moving touches the row; the tray would announce the file as ready again.
|
||||
from sqlalchemy import update
|
||||
|
||||
user = make_user()
|
||||
old = _file(db, user.id, title="Old")
|
||||
db.execute(
|
||||
update(DownloadJob)
|
||||
.where(DownloadJob.id == old.id)
|
||||
.values(updated_at=NOW - timedelta(days=30), ready_at=NOW - timedelta(days=30))
|
||||
)
|
||||
db.commit()
|
||||
c = as_user(user)
|
||||
assert (
|
||||
c.patch(f"/api/files/{old.id}", json={"name": "Renamed", "kept": True}).status_code == 200
|
||||
)
|
||||
assert [row["id"] for row in c.get("/api/downloads").json()] == []
|
||||
|
||||
Reference in New Issue
Block a user