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:
2026-10-13 00:14:09 +02:00
parent 96509fe356
commit 815083bcc4
29 changed files with 593 additions and 92 deletions
+55 -1
View File
@@ -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()] == []