fix(files): review round 3, the server — a fetched locked re-read, a finish that follows its file, an eviction that counts again

- set_retention and delete_own re-read the folder under its lock and use that row (an unfetched ORM
  result never refreshes the object); a folder gone meanwhile is refused (409), and the route audits
  the retention actually replaced
- a finishing download is stamped only while it is still in the folder (and keep) it was read with:
  a move or a folder delete landing meanwhile sends the read round again, so no file is done with no
  expiry
- the eviction pass counts the disk before each candidate and skips one asked for since it began
- docs: source builds get their own volume-copy snippet
- race tests assert the lock wait; one built on a committing delete never raced
This commit is contained in:
2026-10-13 03:15:59 +02:00
parent 5bb101e493
commit c9baf68b16
6 changed files with 261 additions and 53 deletions
+20 -11
View File
@@ -211,21 +211,30 @@ def move(db: Session, job: DownloadJob, folder: FileFolder) -> None:
restamp(db, job) restamp(db, job)
def set_retention(db: Session, folder: FileFolder, days: int | None) -> None: def _hold_for_change(db: Session, folder: FileFolder) -> None:
"""Change how long `folder` keeps a file (None = until deleted). The done files already in it """Lock `folder` FOR UPDATE and read it afresh into the same object: a change waits out a file
move by the difference — each keeps the age it has — but none ends up closer than the GC's being put in it or stamped from it right now (`own_folder`, `done_expiry`), and works from what
warning window, so a shortened retention still warns before it takes anything.""" another change that landed first left, not from what the caller read before the wait. The row is
# Wait out a file being put in it or stamped from it right now (`own_folder`, `done_expiry`), so consumed on purpose: an ORM result refreshes the object only as its rows are fetched."""
# that file is among the ones restamped below. Re-read: another change may have landed first. fresh = db.execute(
db.execute(
select(FileFolder) select(FileFolder)
.where(FileFolder.id == folder.id) .where(FileFolder.id == folder.id)
.with_for_update() .with_for_update()
.execution_options(populate_existing=True) .execution_options(populate_existing=True)
) ).scalar_one_or_none()
if fresh is None:
raise FolderError("file_folder_gone")
def set_retention(db: Session, folder: FileFolder, days: int | None) -> int | None:
"""Change how long `folder` keeps a file (None = until deleted). The done files already in it
move by the difference — each keeps the age it has — but none ends up closer than the GC's
warning window, so a shortened retention still warns before it takes anything. Returns the
retention it replaced (equal to `days` when nothing changed)."""
_hold_for_change(db, folder) # so the file arriving now is among the ones restamped below
old = folder.retention_days old = folder.retention_days
if old == days: if old == days:
return return old
folder.retention_days = days folder.retention_days = days
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
floor = now + timedelta(days=sysconfig.get_int(db, "download_gc_grace_days")) floor = now + timedelta(days=sysconfig.get_int(db, "download_gc_grace_days"))
@@ -248,6 +257,7 @@ def set_retention(db: Session, folder: FileFolder, days: int | None) -> None:
else: else:
job.expires_at = max(job.expires_at + timedelta(days=days - old), floor) job.expires_at = max(job.expires_at + timedelta(days=days - old), floor)
job.expiry_warned = False job.expiry_warned = False
return old
def delete_own(db: Session, folder: FileFolder) -> int: def delete_own(db: Session, folder: FileFolder) -> int:
@@ -255,8 +265,7 @@ def delete_own(db: Session, folder: FileFolder) -> int:
folder it came from, its clock starting there now. Returns how many moved.""" folder it came from, its clock starting there now. Returns how many moved."""
if folder.kind != FolderKind.OWN: if folder.kind != FolderKind.OWN:
raise FolderError("file_folder_not_own") raise FolderError("file_folder_not_own")
# Wait out a file being put in it right now (`own_folder`), then take that one home too. _hold_for_change(db, folder) # so the file arriving now is taken home too
db.execute(select(FileFolder.id).where(FileFolder.id == folder.id).with_for_update())
# Locked as they are read: a file moved out meanwhile stays where it went, and one deleted # Locked as they are read: a file moved out meanwhile stays where it went, and one deleted
# meanwhile is not written back (its gone row would fail the delete). # meanwhile is not written back (its gone row would fail the delete).
jobs = ( jobs = (
+12 -13
View File
@@ -276,8 +276,7 @@ def run_download_gc(db: Session) -> dict:
# fetch again) and never a file anyone kept; an evicted file leaves every holder's library. # fetch again) and never a file anyone kept; an evicted file leaves every holder's library.
cap = sysconfig.get_int(db, "download_total_max_bytes") cap = sysconfig.get_int(db, "download_total_max_bytes")
if cap > 0: if cap > 0:
total = _on_disk(db) if _on_disk(db) > cap:
if total > cap:
kept_assets = select(DownloadJob.asset_id).where( kept_assets = select(DownloadJob.asset_id).where(
DownloadJob.kept.is_(True), DownloadJob.asset_id.is_not(None) DownloadJob.kept.is_(True), DownloadJob.asset_id.is_not(None)
) )
@@ -296,21 +295,21 @@ def run_download_gc(db: Session) -> dict:
) )
db.rollback() db.rollback()
for asset_id in candidate_ids: for asset_id in candidate_ids:
if total <= cap: # Counted afresh before each: a file deleted since (this one, an upload, a candidate
# further down) has freed its space, and no other user's file goes for that space.
if _on_disk(db) <= cap:
break break
# Locked and re-read like an expiry: a holder may have kept it, or every holder # Locked and re-read like an expiry: since the SELECT a holder may have kept it,
# deleted it since the SELECT. # every holder deleted it, or someone asked for it again (a fresh cache hit).
asset, holders = _relock_evictable(db, asset_id) asset, holders = _relock_evictable(db, asset_id)
if asset is None or asset.status != "ready": if (
# Its file went (or is being fetched afresh) since the sum: the disk is counted asset is None
# again, or the pass would evict another user's file for space already free. or asset.status != "ready"
db.commit() or any(j.kept for j in holders)
total = _on_disk(db) or (asset.last_access_at is not None and asset.last_access_at > now)
continue ):
if any(j.kept for j in holders):
db.commit() db.commit()
continue continue
total -= asset.size_bytes or 0
for job in holders: for job in holders:
_notify(db, job, asset, "download_evicted") _notify(db, job, asset, "download_evicted")
db.delete(job) db.delete(job)
+36 -11
View File
@@ -421,17 +421,7 @@ def finalize_if_running(db: Session, job_id: int, **fields) -> bool:
return False return False
db.flush() # asset-then-job lock order: push pending asset writes before we lock the job row db.flush() # asset-then-job lock order: push pending asset writes before we lock the job row
if fields.get("status") == JobStatus.DONE: if fields.get("status") == JobStatus.DONE:
fields = {**fields, "ready_at": datetime.now(timezone.utc)} return _finalize_done(db, job_id, {**fields, "ready_at": datetime.now(timezone.utc)})
# Done is when a file's retention starts: its folder's, unless the user kept it.
held = db.execute(
select(DownloadJob.folder_id, DownloadJob.kept).where(DownloadJob.id == job_id)
).one_or_none()
if held is not None:
fields = {
**fields,
"expires_at": folders.done_expiry(db, held.folder_id, held.kept),
"expiry_warned": False,
}
# A Core UPDATE with the guard as a WHERE — same atomic "only while still running" write as the # A Core UPDATE with the guard as a WHERE — same atomic "only while still running" write as the
# rest of the codebase's 2.0 statements, no hand-built SQL string. synchronize_session=False: this # rest of the codebase's 2.0 statements, no hand-built SQL string. synchronize_session=False: this
# is a terminal write, so we don't pay to sync the (about-to-be-discarded) ORM identity map. Bound # is a terminal write, so we don't pay to sync the (about-to-be-discarded) ORM identity map. Bound
@@ -445,6 +435,41 @@ def finalize_if_running(db: Session, job_id: int, **fields) -> bool:
return result.rowcount > 0 return result.rowcount > 0
def _finalize_done(db: Session, job_id: int, fields: dict) -> bool:
"""`finalize_if_running` for a job that finished: done is when a file's retention starts, its
folder's unless the user kept it. The expiry is written only while the job is still where it was
read from — a move, a keep, or its folder's delete (which sends its files home) landing while the
folder's retention was read sends the read round again, never stamping a folder's retention on a
file now in another one."""
while True:
held = db.execute(
select(DownloadJob.folder_id, DownloadJob.kept, DownloadJob.status).where(
DownloadJob.id == job_id
)
).one_or_none()
if held is None or held.status != JobStatus.RUNNING:
return False
stamped = {
**fields,
"expires_at": folders.done_expiry(db, held.folder_id, held.kept),
"expiry_warned": False,
}
hit = db.execute(
update(DownloadJob)
.where(
DownloadJob.id == job_id,
DownloadJob.status == JobStatus.RUNNING.value,
DownloadJob.folder_id.is_not_distinct_from(held.folder_id),
DownloadJob.kept == held.kept,
)
.values(**stamped)
.returning(DownloadJob.id)
.execution_options(synchronize_session=False)
).scalar_one_or_none()
if hit is not None:
return True
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,
+16 -12
View File
@@ -421,18 +421,22 @@ def update_folder(
target_type="file_folder", target_type="file_folder",
target_id=folder.id, target_id=folder.id,
) )
if "retention_days" in sent and body.retention_days != folder.retention_days: if "retention_days" in sent:
before = folder.retention_days # The value replaced is the one read under the folder's lock, not the one read above it.
folders.set_retention(db, folder, body.retention_days) try:
audit.record( before = folders.set_retention(db, folder, body.retention_days)
db, except folders.FolderError as e:
user.id, raise refused(409, e.reason) from e
AuditAction.FILE_FOLDER_RETENTION, if before != body.retention_days:
target_type="file_folder", audit.record(
target_id=folder.id, db,
before={"retention_days": before}, user.id,
after={"retention_days": body.retention_days}, AuditAction.FILE_FOLDER_RETENTION,
) target_type="file_folder",
target_id=folder.id,
before={"retention_days": before},
after={"retention_days": body.retention_days},
)
db.commit() db.commit()
count, size = _folder_totals(db, folder.id) count, size = _folder_totals(db, folder.id)
return _serialize_folder(folder, count, size) return _serialize_folder(folder, count, size)
+165 -4
View File
@@ -501,10 +501,11 @@ def test_a_retention_changed_while_a_file_goes_in_times_that_file_too(db, make_u
lambda other: folders.set_retention(other, other.get(FileFolder, own_id), None) lambda other: folders.set_retention(other, other.get(FileFolder, own_id), None)
) )
t.join(timeout=1) t.join(timeout=1)
assert t.is_alive() # the change waits for the file being put in
folders.move(db, job, target) # stamped with the 7 days it sees folders.move(db, job, target) # stamped with the 7 days it sees
db.commit() db.commit()
t.join(timeout=10) t.join(timeout=10)
assert outcome == [None] assert outcome == [7] # the retention it replaced
db.expire_all() db.expire_all()
assert db.get(DownloadJob, job.id).expires_at is None assert db.get(DownloadJob, job.id).expires_at is None
@@ -527,13 +528,21 @@ def test_a_retention_changed_while_a_file_finishes_there_times_that_file_too(db,
lambda other: folders.set_retention(other, other.get(FileFolder, own_id), None) lambda other: folders.set_retention(other, other.get(FileFolder, own_id), None)
) )
t.join(timeout=1) t.join(timeout=1)
assert t.is_alive() # the change waits for the file being stamped
db.commit() db.commit()
t.join(timeout=10) t.join(timeout=10)
assert outcome == [None] assert outcome == [7] # the retention it replaced
db.expire_all() db.expire_all()
assert db.get(DownloadJob, job.id).expires_at is None assert db.get(DownloadJob, job.id).expires_at is None
def _deleted_uncommitted(db, job):
"""`service.delete` up to its commit: the file is gone in this session, its row still locked."""
service.release_asset(db, job)
db.delete(job)
db.flush()
def test_a_file_moved_out_while_its_folder_is_deleted_stays_where_it_went(db, make_user): def test_a_file_moved_out_while_its_folder_is_deleted_stays_where_it_went(db, make_user):
uid = make_user().id uid = make_user().id
f = FileFolder(user_id=uid, kind=folders.FolderKind.OWN, name="F") f = FileFolder(user_id=uid, kind=folders.FolderKind.OWN, name="F")
@@ -566,10 +575,10 @@ def test_a_file_deleted_while_its_folder_is_deleted_does_not_fail_the_delete(db,
job = _job(db, uid, _asset(db)) job = _job(db, uid, _asset(db))
folders.move(db, job, f) folders.move(db, job, f)
db.commit() db.commit()
service.delete(db, db.get(DownloadJob, job.id)) _deleted_uncommitted(db, db.get(DownloadJob, job.id))
db.flush() # deleted, not yet committed
t, outcome = _in_thread(lambda other: folders.delete_own(other, other.get(FileFolder, f_id))) t, outcome = _in_thread(lambda other: folders.delete_own(other, other.get(FileFolder, f_id)))
t.join(timeout=1) t.join(timeout=1)
assert t.is_alive() # the delete waits for the file being deleted
db.commit() db.commit()
t.join(timeout=10) t.join(timeout=10)
assert outcome == [0] assert outcome == [0]
@@ -577,6 +586,158 @@ def test_a_file_deleted_while_its_folder_is_deleted_does_not_fail_the_delete(db,
assert db.get(FileFolder, f_id) is None assert db.get(FileFolder, f_id) is None
def _own_with_a_file(db, uid, retention=30):
"""A user's own folder keeping files `retention` days, holding one done file due in as many."""
own = FileFolder(
user_id=uid, kind=folders.FolderKind.OWN, name="Mine", retention_days=retention
)
db.add(own)
db.flush()
job = _job(db, uid, _asset(db))
job.folder_id = own.id
job.expires_at = NOW + timedelta(days=retention)
db.commit()
return own.id, job.id
def _elsewhere(act):
other = SessionLocal()
try:
act(other)
other.commit()
finally:
other.close()
def test_a_retention_change_works_from_what_a_change_before_it_left(db, make_user):
# The locked re-read was never fetched, so the change shifted the files from the value read before
# its wait: 30 -> 10 -> 60 left them due in 40 days under a folder saying 60.
own_id, job_id = _own_with_a_file(db, make_user().id)
stale = db.get(FileFolder, own_id) # read before the other change lands
_elsewhere(lambda other: folders.set_retention(other, other.get(FileFolder, own_id), 10))
assert folders.set_retention(db, stale, 60) == 10
db.commit()
db.expire_all()
due = db.get(DownloadJob, job_id).expires_at - NOW
assert timedelta(days=59) < due < timedelta(days=61)
def test_a_retention_change_to_a_folder_deleted_meanwhile_is_refused(db, make_user):
own_id, _ = _own_with_a_file(db, make_user().id)
stale = db.get(FileFolder, own_id)
_elsewhere(lambda other: folders.delete_own(other, other.get(FileFolder, own_id)))
with pytest.raises(folders.FolderError, match="file_folder_gone"):
folders.set_retention(db, stale, 60)
def test_a_file_moved_out_while_its_folder_retention_changes_keeps_its_new_folders(db, make_user):
uid = make_user().id
own_id, job_id = _own_with_a_file(db, uid)
g = FileFolder(user_id=uid, kind=folders.FolderKind.OWN, name="G", retention_days=7)
db.add(g)
db.commit()
g_id = g.id
folders.move(db, db.get(DownloadJob, job_id), folders.own_folder(db, uid, g_id))
db.flush() # the move is written, not yet committed
t, outcome = _in_thread(
lambda other: folders.set_retention(other, other.get(FileFolder, own_id), None)
)
t.join(timeout=1)
assert t.is_alive() # the change waits for the file it would time
db.commit()
t.join(timeout=10)
assert outcome == [30]
db.expire_all()
job = db.get(DownloadJob, job_id)
assert job.folder_id == g_id
assert job.expires_at is not None # G's 7 days, not the forever of the folder it left
def test_a_file_deleted_while_its_folder_retention_changes_does_not_fail_the_change(
db, make_user, _root
):
(_root / "a.mp4").write_bytes(b"x")
own_id, job_id = _own_with_a_file(db, make_user().id)
_deleted_uncommitted(db, db.get(DownloadJob, job_id))
t, outcome = _in_thread(
lambda other: folders.set_retention(other, other.get(FileFolder, own_id), None)
)
t.join(timeout=1)
assert t.is_alive() # the change waits for the file it would time
db.commit()
t.join(timeout=10)
assert outcome == [30]
def test_a_file_finishing_while_its_folder_is_deleted_gets_its_new_folders_retention(db, make_user):
# The finish read the folder unlocked, then waited out the delete and found no folder: the file
# was done with no expiry at all, in a source folder that keeps files 30 days.
uid = make_user().id
own = FileFolder(user_id=uid, kind=folders.FolderKind.OWN, name="Mine", retention_days=7)
db.add(own)
db.commit()
own_id = own.id
job = _job(db, uid, _asset(db), status=JobStatus.RUNNING)
job_id = job.id
folders.move(db, job, own)
db.commit()
folders.delete_own(db, db.get(FileFolder, own_id))
db.flush() # the delete, not yet committed
t, outcome = _in_thread(
lambda other: service.finalize_if_running(other, job_id, status=JobStatus.DONE)
)
t.join(timeout=1)
assert t.is_alive() # the finish waits for the delete
db.commit()
t.join(timeout=10)
assert outcome == [True]
db.expire_all()
done = db.get(DownloadJob, job_id)
assert done.folder_id != own_id
assert done.expires_at is not None
def _an_upload_over_the_cap_too(db, uid, _root):
(_root / "u.bin").write_bytes(b"x")
return _job(db, uid, _asset(db, "upload", "u1", rel="u.bin", size=10), kind="upload")
def test_space_freed_elsewhere_while_the_cache_is_over_its_cap_evicts_no_more(
db, make_user, _root, monkeypatch
):
# The pass counted the disk once: an upload deleted meanwhile still counted, so a second user's
# file went for space already free.
jold, jnew = _two_over_the_cap(db, make_user, _root)
up = _an_upload_over_the_cap_too(db, make_user().id, _root)
def upload_deleted(other, asset_id):
if asset_id == jold.asset_id:
service.delete(other, other.get(DownloadJob, up.id))
_meanwhile(monkeypatch, upload_deleted, step="_relock_evictable")
assert _gc(db)["evicted"] == 1
db.expire_all()
assert db.get(DownloadJob, jnew.id) is not None
assert (_root / "new.mp4").exists()
def test_a_file_asked_for_again_while_the_cache_is_over_its_cap_stays(
db, make_user, _root, monkeypatch
):
# A cache hit between the pass's SELECT and its lock: the file the user just asked for went.
jold, jnew = _two_over_the_cap(db, make_user, _root)
def asked_again(other, asset_id):
if asset_id == jold.asset_id:
other.get(MediaAsset, asset_id).last_access_at = datetime.now(timezone.utc)
_meanwhile(monkeypatch, asked_again, step="_relock_evictable")
_gc(db)
db.expire_all()
assert db.get(DownloadJob, jold.id) is not None
assert (_root / "old.mp4").exists()
def test_a_file_asked_for_while_its_cached_copy_is_freed_gets_a_fresh_one(db, make_user): def test_a_file_asked_for_while_its_cached_copy_is_freed_gets_a_fresh_one(db, make_user):
# The enqueue read the cached copy unlocked: freed meanwhile, its job pointed at a row that was gone # The enqueue read the cached copy unlocked: freed meanwhile, its job pointed at a row that was gone
# (a foreign-key failure, a 500). # (a foreign-key failure, a 500).
+12 -2
View File
@@ -279,8 +279,18 @@ that name. To move to the new layout, download the new
Open the Files library and check your files play; only then remove the old volume Open the Files library and check your files play; only then remove the old volume
(`docker volume rm "$OLD"`). (`docker volume rm "$OLD"`).
- **If you build from source**, a `git pull` brings the new `docker-compose.yml`, whose default - **If you build from source**, a `git pull` brings the new `docker-compose.yml`, whose default
volume is `files` (it was `downloads`): copy it over the same way, with `downloads` and `files` in volume is `files` (it was `downloads`). Copy it over before you start the new version — with your
place of `siftlode_downloads` and `siftlode_files`, before you start the new version. own compose file, not the self-host one above, whose volumes are other ones:
```bash
docker compose down
docker volume ls --format '{{.Name}}' | grep '_downloads$' # e.g. siftlode_downloads
OLD=siftlode_downloads # the name it printed
NEW="${OLD%downloads}files"
docker volume create "$NEW"
docker run --rm -v "$OLD":/from -v "$NEW":/to alpine cp -a /from/. /to/
docker compose up -d --build
```
If the files library is not mounted where the app looks, the API says so in its log at start If the files library is not mounted where the app looks, the API says so in its log at start
(`The files library is not reachable`), and the download worker refuses to start rather than write (`The files library is not reachable`), and the download worker refuses to start rather than write