Conversation
…#2710) SearchAndGrabBook looked up the release's GUID in downloads and skipped the grab whenever a row came back, whatever state that row was in. Any finished attempt therefore pinned its release for good: the reporter had twenty five rows failed months earlier on a loopback URL refusal from before BINDERY_DOWNLOAD_ALLOW_LOOPBACK was set, and on indexer 429 and 500 responses during one afternoon, and every automatic re-grab of those releases was dropped. Deleting the queue rows by hand was the only way out, and one transient 429 was enough to poison a release permanently. The manual grab has decided this correctly since #1955: only live work blocks, where dead means failed or importBlocked. That predicate moves into models as DownloadState.IsDeadForRegrab and Download.BlocksRegrab, api.regrabbable delegates to it, and the scheduler now uses it too, so the two paths cannot drift apart. The scheduler adds a cooldown the manual path does not need. A release that fails at the download client fails again the moment it is re-sent, and only the stall handler blocklists, so reusing a dead row unconditionally would re-grab such a release on every sweep, as often as hourly. A dead row is therefore reused only once it has been idle for six hours: shorter than the twelve hour default search cadence, so the ordinary next sweep of that book retries the release, and longer than the one hour minimum. An orphaned import is still reused whatever its age (#2289), since nothing about it changes with time. RetryOrphanedImport becomes RetryDeadForAutoGrab and carries both conditions in SQL, including the cooldown, so a row a manual grab claimed and failed between the scheduler's read and its claim is not mistaken for one that died months ago. It resets every per grab column as before, so the reused row, its history entry and its queue entry describe the new grab alone. The skip is no longer silent. It was a bare return after the "auto-grabbing book" line, so the log read as though the grab had gone ahead. Each skip now logs the release, its GUID, the blocking row's id and status and the reason, and the "book search finished" outcome names the reason and that status instead of a flat "already grabbed". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016fJcCVbNnKsmj2MAAMwWj9 Signed-off-by: vavallee <vavallee@protonmail.com>
…plicit Delegating regrabbable to models.Download.BlocksRegrab left api.orphanedImport with no caller, which golangci-lint's unused check fails on, and dropped the comment that explains the #2289 case at the place the manual grab decides it. regrabbable goes back to spelling out both halves; each one delegates to the models predicate, so the manual and automatic gates still cannot drift. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016fJcCVbNnKsmj2MAAMwWj9 Signed-off-by: vavallee <vavallee@protonmail.com>
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
…ctly The scheduler and repo tests exercise BlocksRegrab and LastActivityAt through their callers, which leaves both uncovered in the models package's own report and leaves the branches no caller reaches untested: a nil row, and a row whose completed_at is newer than the added_at the cooldown would otherwise measure from. Both are table tests over every download state. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016fJcCVbNnKsmj2MAAMwWj9 Signed-off-by: vavallee <vavallee@protonmail.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Automatic search checked only whether the queue held a row for the release's GUID, so any row stopped the grab, including one that had failed months earlier. The reporter had twenty five rows failed on a loopback URL refusal from before
BINDERY_DOWNLOAD_ALLOW_LOOPBACKwas set and on indexer 429 and 500 responses from one bad afternoon, and every automatic re grab of those releases was dropped, silently, after the "auto-grabbing book" line had already been logged. This makes a finished attempt stop blocking, reuses its row when the grab goes ahead, and makes every skip say why. Closes #2710.Which states block, and why
The manual grab has decided this correctly since #1955. The predicate moves into
models(DownloadState.IsDeadForRegrab,Download.BlocksRegrab),api.regrabbabledelegates to it, and the scheduler now shares it, so the two paths cannot drift.grabbed,downloading,completed,importPending,importingimportExternal,importHeldimportFailedimportedwith its bookimportedwith no book (orphaned import)failedimportBlockedThe cooldown, and the blocklist question
A dead row is only reused once its last activity is at least six hours old. That is the interesting judgement call here, so the reasoning in full:
markDownloadFailed(a torrent erroring in the client, a SABnzbd failure) does not, and the send failure path inSearchAndGrabBookdoes not either. So the blocklist does not cap the general case, and without a bound the sweep would re grab a client side failure on every pass, which can be hourly (search.intervalminimum is 1h, default 12h).RetryFailedis untouched and still reuses a row that failed seconds ago.Combined behaviour after this change, for a release that keeps failing:
BlocklistedSpecthen rejects it at decision time, so it is never picked again and the cooldown never comes into it. Unchanged.Reuse rather than a second row
RetryOrphanedImportbecomesRetryDeadForAutoGrab(ctx, d, idleBefore)and carries both conditions in SQL: dead and idle past the cutoff, or an orphaned import. It reusesregrabClaimSQL, so the row keeps its id andguidand every per grab column is rewritten or reset (error_message,import_path,sabnzbd_nzo_id,torrent_id,owner_user_id,added_at,grabbed_at,completed_at,imported_at,import_retry_count). The queue therefore shows one row describing this grab, and the history event written after the send describes this grab alone.downloads.guidisUNIQUE, so reuse is also the only shape that works without deleting the old row.The cooldown is in the SQL rather than only in the caller for the same reason the states are: between the scheduler reading the row and claiming it, a manual grab can claim it and fail, leaving a row that died seconds ago where the scheduler saw one that died months ago.
downloadshas noupdated_at, so "last activity" isCOALESCE(completed_at, grabbed_at, added_at), mirrored in Go byDownload.LastActivityAt. Times are stored as RFC3339 UTC (verified against modernc/sqlite:2026-09-19T03:11:58.643383413Z), so a boundtime.Timecompares correctly;julianday()returns NULL on that format, so it is deliberately not used.Making the skip visible
The existing end of run line is
slog.Info("book search finished", ..., "outcome", ...). It has no GUID and no download id, so the outcome alone cannot answer "which row held it back". Both are now emitted:outcomebecomesalready grabbed (downloading)/failed too recently (failed)instead of a flatalready grabbed.skipping a release the queue still holdswithbook,release,guid,existing_download_id,existing_status,reason. INFO rather than DEBUG because it fires at most once per book per format per sweep, and it is the line the reporter went looking for and did not find.claimed by another grab) rather than reusing "already grabbed".Fail before evidence
Base is
d2bec3b0(origin/main at branch time). Each run below puts the base source back under the new tests.internal/scheduler/scheduler.goreverted to base (with a shim restoring the baseRetryOrphanedImport), new tests unchanged:(the other three
StillSkipsKnownReleasecases fail the same way on the log assertions; they already refused the grab on base, which is the point of keeping them)internal/db/downloads.goclaim reverted to the baseRetryOrphanedImport:What the new tests cover: a stale
failedand a staleimportBlockedrow are re grabbed and the reused row is coherent (id, book, owner, title, url, cleared error and import path, new nzo id, statusdownloading, exactly one row); a row that died seconds ago, an in flight row, animportFailedrow and animportedrow with its book are all still refused and left untouched; the orphaned import path still passes unchanged (TestSearchAndGrabFormat_ReusesOrphanedImport, untouched); every skip is asserted present in the logs with GUID, status and reason; at the repo layer, animportBlockedrow added months ago butcompleted_ata minute ago is still inside the cooldown, andRetryFailedstill accepts a row that failed seconds ago for the manual grab.Security
No new endpoint, no new setting, no new dependency. The one thing worth naming is tenancy (#1457): the reused row is claimed with
owner_user_id = book.OwnerUserID, exactly asRetryOrphanedImportdid, so a dead row left by one user and re grabbed for another user's book moves to that book's owner rather than staying in the first user's queue. Asserted inTestSearchAndGrabFormat_ReusesStaleFailedRow. The manual grab's gate is unchanged in behaviour;regrabbablenow delegates tomodels.Download.BlocksRegrab, which is the same predicate it computed inline.Performance
No new query. The GUID lookup was already there, and the claim is the same single
UPDATEwith two extra conditions in itsWHERE. No new index needed: the claim is keyed onid.Checklist
docs/Troubleshooting-Wiki.mdgains a section on the automatic search side of this; godoc on every touched function)changelog.d/2710-failed-row-blocks-regrab.md, credits ccarpinteri)Test plan
go build ./...,go vet ./...go test ./internal/scheduler/... ./internal/api/... ./internal/db/... ./internal/models/... ./internal/importer/...go test -race ./internal/scheduler/ ./internal/db/ ./internal/models/on the touched testscd web && npm run build(no frontend change in this PR)🤖 Generated with Claude Code
https://claude.ai/code/session_016fJcCVbNnKsmj2MAAMwWj9