Repository navigation
fix: keep media references when a file cannot be deleted - #149
Conversation
Deleting a media item's files ignored real `File.rm` failures. An I/O,
permission or read-only-filesystem error left the file on disk while the
stored filepaths were cleared and the `media_deleted` hook fired, so the
record stopped pointing at the retained file and the deletion could not be
retried through it. The UI flashed "Files deleted successfully."
- `Media.delete_media_files/2` and `Media.delete_media_item/2` now attempt
every attached file and return
`{:error, {:file_removal_failed, [{filepath, reason}]}}`, leaving the
record, its filepath attributes, `addl_attrs` and the `media_deleted`
user script untouched. `{:error, :enoent}` counts as success so retrying
a partially-completed deletion converges.
- `do_delete_media_files/1` logs the failed paths once at the detection
point, so the retention worker's discarded result stays traceable.
- `delete_media_item/2` gained `ignore_file_removal_errors`, used by
`Sources.delete_source/2`: `media_items.source_id` is
`references(:sources, on_delete: :restrict)`, so a surviving media row
would make the cascade raise instead of deleting the source.
- The media delete action flashes `:error` naming the failed files instead
of hard-matching `{:ok, _}`.
Follow-up to the file-removal-failure fix: - assert the media_deleted user script does not fire when a removal fails, that a retry converges once the blocker is cleared, and that delete_media_item/2 with delete_files: true treats an already-gone file as success - document that ignore_file_removal_errors suppresses the media_deleted hook, and reword the comment that no longer reads accurately inside the with-block
The new 'logs a warning naming the file it could not remove' test relied on ExUnit.CaptureLog's :level option to surface the warning. That option does not override the primary Logger.level/0, which config/test.exs pins to :critical; the warning is filtered out before the capture handler sees it, leaving capture_log empty. Use the same Logger.configure + on_exit pattern already used in test/pinchflat/utils/filesystem_utils_test.exs and test/pinchflat/slow_indexing/slow_indexing_helpers_test.exs. Also note in the delete_media_item/2 docstring that internal metadata files linger on a failed file removal — the old code removed them unconditionally, the new code leaves them with the record so a retry can finish, but the docstring was silent on that.
|
Fix-up addressing the The single failing test — Fix: switched to the same Info-level review finding addressed — Local validation in this lane
Head: |
|
🐳 Image for commit ecc24a0 published: |
Superseded by a newer automated review for this pull request.
There was a problem hiding this comment.
AI Automated Review
Full PR review.
Analysis engine: glm-5.3-flash (openai) — routed smart (risk match: file_serving_changes) · pr-reviewer-action v3.2.0
Partial coverage: required-check coverage is incomplete — this review did not resolve every required check and must not be read as a complete pass.
Recommendation: Approve. The PR correctly implements issue PR 138: file-removal failures now propagate as {:error, {:file_removal_failed, [{filepath, reason}]}}, filepath references and the record are preserved, the media_deleted hook fires only on complete success, and the controller reports the failed files instead of claiming success. CI ("Lint and Test", plus both image builds) is green on the head commit ecc24a0. All five PR claims were checked against the code and held.
Change-by-change findings
lib/pinchflat/media/media.ex—delete_media_item/2now gates record deletion behinddelete_attached_files_if_required/3via awith, anddelete_media_files/2gates the hook + filepath-attribute reset behinddo_delete_media_files/1. Both return the new error tuple on failure. The:enoent-as-success mapping indelete_attached_file/1is what makes retries converge, and it is pinned by tests both ways ("a retry converges once the blocker is cleared", "resets the filepath attributes when the only missing file was already gone"). Theignore_file_removal_errorsoption suppresses the hook and deletes the record anyway with a warning — exactly as documented in the new@doc. The intentional behavior change (internal metadata files now linger with the record on a failed deletion instead of being removed "no matter what") is explicitly documented in the docstring, which is the correct call: deleting metadata that describes a record you are keeping would recreate the reference-loss bug.lib/pinchflat_web/controllers/media_items/media_item_controller.ex— the hard{:ok, _} =match is gone; the action now handles{:error, {:file_removal_failed, failures}}(error flash naming the files) and a generic{:error, _}fallback. Flash content is HTML-escaped by Phoenix on render, so interpolating stored filepaths is safe.lib/pinchflat/sources/sources.ex—delete_source/2passesignore_file_removal_errors: true, which is required becausemedia_items.source_idisreferences(:sources, on_delete: :restrict)(confirmed inpriv/repo/migrations/20240125025325_create_media_items.exs:9); without it a surviving media row would raise on the cascade. The chainProfiles.delete_media_profile/2→Sources.delete_source/2→Media.delete_media_item/2andMediaProfileDeletionWorker→Profiles.delete_media_profile/2(verified inlib/pinchflat/profiles/profiles.ex:56-64andmedia_profile_deletion_worker.ex:34) inherit the suppression, so no cascade caller sees the new tuple.lib/pinchflat/utils/filesystem_utils.ex— doc-only change; the:ok-on-partial-cleanup-failure contract and the error propagation fromFile.rm/1are unchanged, and the updated caller list matches the actual call sites found by grep (media.ex:267,media.ex:281,sources.ex:184,sources.ex:194,file_syncing.ex:67).lib/pinchflat/downloading/media_retention_worker.ex(unchanged, verified) — both call sites (:45,:70) discard the result insideEnum.each, so the new error tuple cannot crash the worker; because a failed cull leaves the record and itsmedia_filepathintact and does not setprevent_download/culled_at, the item remains matched byMediaQuery.cullable/0(downloaded+past_retention_period+ notculling_prevented) and is re-selected on the next daily run. The claim's retry story holds.- Tests — 202 added lines covering: error return with references preserved, other files still removed, warning logged (with the
Logger.configurelevel-bump pattern matching the existingfilesystem_utils_test.exsidiom), hook not fired on failure / fired on convergence retry and on already-gone files,ignore_file_removal_errorsdeleting anyway, source cascade surviving an undeletable media file, and the controller error flash with retained reference. All new test paths are derived from fixture locations viaPath.dirname/Path.join— no absolute checkout paths.
Claims to falsify — results
- Claim 1 (error tuple from
delete_media_files/2/delete_media_item/2) — held (12/12 listed items + sibling scan). Verified inmedia.ex(do_delete_media_files→log_file_removal_failuresreturns the tuple; both public functions gate on:ok). A repo-wide grep fordelete_media_files|delete_media_itemfound no production call site beyond the retention worker (discarding),sources.ex:148, and the controller — none missed by the list. - Claim 2 (controller error flash, no hard
{:ok, _}match) — held (12/12). The controller diff replaces the match with a three-waycase; the error flash names the failed files viafile_removal_failure_message/1. - Claim 3 (old behavior lost references and fired the hook on failure) — held (6/6). Consistent with the issue's line-level evidence; the new single gate (
run_user_script(:media_deleted, ...)appears only atmedia.ex:223andmedia.ex:289, both inside success branches) is confirmed, and repo-wide grep shows no other production emitter of the:media_deletedevent. - Claim 4 (failed removal never clears references or deletes the record; consumers safe) — held (11/11). Controller handles both error shapes;
MediaRetentionWorkerdiscards results at both sites; the:restrictFK is real at the cited migration line;Sources.delete_source/2suppression and theProfiles/MediaProfileDeletionWorkerinheritance chain verified. - Claim 5 (no other call sites; hook single-gated; cascade never raises) — held (12/12). Grep over
lib/andtest/found no additional callers of the two functions or ofdelete_file_and_remove_empty_directoriesbeyond those accounted for; the discarding callers named in the updated@docall match real, unchanged call sites.
Standards Compliance
- Alias nested modules (Credo AliasUsage) — followed: all new
FilesystemUtilscalls inmedia.exuse the aliased form, consistent with the file's pre-existing usage; the head compiles and CI is green, confirming the alias is in place. - Module name matches filename — no new modules introduced; all touched files keep their existing name correspondence.
- Queue names — no new workers or queues; the touched worker already uses the existing
local_dataqueue. - No assumed checkout paths — new tests derive every path from fixture locations; no absolute paths added.
- Docs — the behavior change is documented where it belongs (the
@docondelete_media_item/2anddelete_source/2, plus thefilesystem_utils.excaller contract). AGENTS.md/CODEBASE.md/DEVELOPMENT.md do not document media-deletion error behavior, so no companion-doc update is required.
Linked Issue Fit
Issue PR 138's acceptance criteria are all met: (1) deterministic removal failures (:eacces/:erofs class) return an actionable error identifying the file without clearing its filepath or deleting the record — tested; (2) no success hook or success flash on failure — hook suppression tested with expect(..., 0, ...) and the controller test asserts the error flash and absence of info; (3) partial deletion handled deliberately — other files are still removed, :enoent converges retries, and the retry test pins completion; (4) context, controller, and source-cascade regression tests added, successful deletions and the parent-directory warning contract intact; (5) targeted tests and mix check --no-fix --no-retry ran green in CI on the head commit. The scope exclusion (empty-parent-directory cleanup) is respected — filesystem_utils.ex changed doc-only.
Tool Harness Findings
28 read-only calls: repo-wide greps for delete_media_files|delete_media_item, media_deleted, and delete_file_and_remove_empty_directories (call-site census above); full reads of media.ex (160–309), the controller delete action, sources.ex delete path, profiles.ex, media_retention_worker.ex, filesystem_utils.ex (120–179), the create_media_items migration, media_query.ex cullable/cutoff dynamics, media_item.ex filepath attributes, and the fixture/helper files the new tests rely on. Nothing contradicted the PR's claims; no additional unaccounted callers exist.
Must-check dispositions summary
- File path sanitization / directory traversal (×2) / path traversal review — not applicable to this change: no new path construction from external input; deletion targets only filepath attributes already stored on the record, and the flash output is HTML-escaped.
- Audit findings addressed — satisfied: every acceptance criterion in issue PR 138 is implemented and tested as detailed above.
- Edge-case paths (null bytes, symlinks) — satisfied by design: any
File.rm/1error (including exotic reasons) now fails closed as a surfaced, logged error instead of being silently swallowed, and the new tests exercise the non-regular-file edge case (a directory where a file is expected); symlink semantics viaFile.rm/1are unchanged. No traversal surface is introduced.
Requirement trace
4 of 5 requirement(s) not fully traced to enforcement and a test:
req-21433a4da967— unverifiable (no valid enforcement location)req-f082fd28d0b2— unverifiable (no valid enforcement location)req-259d4903f81b— unverifiable (no valid enforcement location)req-cec06a117f01— unverifiable (no valid enforcement location)
Approval withheld: this review's coverage is incomplete — required-check coverage or the tool-loop investigation did not finish, so it is publishing as an advisory comment rather than an approval.
Closes #138
What's new?
N/A
What's changed?
Media.delete_media_files/2andMedia.delete_media_item/2now attempt every attached file and, when one cannot be removed, return{:error, {:file_removal_failed, [{filepath, reason}]}}instead of unconditionally returning{:ok, media_item}. On failure the record, its filepath attributes andaddl_attrsare left untouched, and themedia_deleteduser script does not fire. The failed paths are logged once as a warning at the detection point.{:error, :enoent}is treated as success: an already-missing file is the state deletion wanted, so retrying a partially-completed deletion converges.Media.delete_media_item/2gained anignore_file_removal_errorsoption, used bySources.delete_source/2:media_items.source_idisreferences(:sources, on_delete: :restrict), so a surviving media row would make the source cascade raise instead of deleting. When set, the record is deleted anyway with a warning, and themedia_deletedhook is suppressed (documented on the option).:errornaming the failed files instead of "Files deleted successfully.", and no longer hard-matches{:ok, _}.delete_source/2test proving the cascade deletes despite an undeletable media file, and a controller test asserting the error flash and retained reference.What's fixed?
An I/O, permission or read-only-filesystem failure during file deletion used to leave the file on disk while the stored filepaths were cleared and the
media_deletedhook fired, so the record stopped pointing at the retained file, the deletion could not be retried through it, and the UI claimed success.Invariants this change must keep (and every other path enforcing the same rule)
MediaItemController.delete/2(handles{:error, {:file_removal_failed, _}}and a generic error tuple with an error flash + redirect; regression test added);MediaRetentionWorker(media_retention_worker.ex:45and:70— discards the result, so the new error tuple cannot crash it, and because references are retained the next daily run re-selects and retries the same items). No other call sites exist inlib/ortest/.media_deletedfires only on complete success (removed or already-gone). Single gate inmedia.ex(delete_attached_files_if_required/ thewithindelete_media_files/2); theignore_file_removal_errorssuppression is documented and pinned by tests both ways (fires on convergence retry, does not fire on failure).on_delete: :restrictFK is real (priv/repo/migrations/20240125025325_create_media_items.exs:9);Sources.delete_source/2passesignore_file_removal_errors: true(test added insources_test.exs);Profiles.delete_media_profile/2→Sources.delete_source/2andMediaProfileDeletionWorkerinherit this through the same chain, so they never see the new tuple.tasks.media_item_id(:restrict) is covered by the pre-existingTasks.delete_tasks_for/1call that still runs first.:enoentmaps to success indelete_attached_file/1, so files removed by a partially-failed first attempt never re-block the retry (pinned by the retry test).FilesystemUtils.delete_file_and_remove_empty_directories/1is unchanged except its@doccaller list; the remaining discarding callers (Media.delete_internal_metadata_files/1,Sources.delete_source_files/1,Sources.delete_internal_metadata_files/1,FileSyncing.handle_file_deletion/2) are untouched, matching the issue's explicit scope exclusion.Any other comments?
Opened by the Courier autonomous loop for audit issue #138.
Validation status: the lane environment has no Elixir/OTP toolchain and no container runtime (verified:
mix/elixir/dockerabsent on both coordinator and worker machines), somix test,mix check,credoand the formatter could not be executed locally — CI ("Lint and Test") is the first executable gate and is explicitly pending. What was done locally: the full diff was read line-by-line, theon_delete: :restrictFK claim and the complete caller inventory (MediaItemController,MediaRetentionWorker,Sources.delete_source→Profiles.delete_media_profile) were verified against the code, and two independent static review passes over the final diff (including the test additions) reported no blockers.