2026-09-09 The Torrent Vanished and Nothing Noticed
What I set out to do
Answer one question: why were three episodes of The Ramparts of Ice showing as unavailable in Sonarr? The season had come in through a Seerr request and 11 of 14 episodes were present, so “just missing upstream” was implausible.
What I actually did
Walked the evidence chain backwards from Sonarr’s history.
- History showed 14
grabbed, 11downloadFolderImported, and zerodownloadFailed. Three grabs simply never resolved into anything. - The three files were sitting orphaned in
~/media/downloads/tv/at full apparent size.ls -lasgave them away: 126 MB actually allocated of a 954 MB file. Sparse stubs, ~10-13% downloaded. sonarr.txthadApplication is shutting down...at 09:35 on 09-08 — three minutes after the grabs. The Docker VM came back ungracefully.- The smoking gun was in
config/qbittorrent/qBittorrent/BT_backup/: the three torrents have a.torrentwith the 09:32 mtime and no.fastresumebeside it.qbittorrent.loglists only the older torrents underRestored torrent.
So: qBittorrent only restores a torrent on startup if resume data exists for it, and that file is written on completion, on graceful shutdown, and otherwise every SaveResumeDataInterval minutes (default 60). Anything grabbed inside the last hour was dropped from the session outright.
Cleaned up the live stack (re-searched the affected episodes; E10 and E11 imported, E01 finished), then shipped the durable fix as media-stack PR #63:
scripts/recover-orphaned-grabs.sh— a sweep in the existingmissing-searchloop that re-searches any grab with no import, no failure, no manual ignore, and no queue entry. 17 unit cases behind a neworphaned-grab-recoveryflake check.Session\SaveResumeDataInterval60 → 3 minutes.Session\TempPathEnabled— it was never set, soTempPathhad been inert since forever and partials went straight into the import folders.
What was striking
*The arrs have no event for a torrent that disappears. Sonarr’s queue is a live projection of the download client, so when the torrent is gone the queue row goes with it. No warning, no downloadFailed, nothing for autoRedownloadFailed to fire on. The episode just quietly reads as missing behind a grabbed row that never settled. Every other failure in this stack announces itself; this one is defined by its silence.
The existing missing-search service could never have caught it. It keys on airDateUtc / added inside a 48h lookback, so it only ever covers new media. This season aired in April and was requested in September — the normal shape of a Seerr back-catalog request, and completely invisible to that window. Walking grab history instead of air date is the whole idea.
Two of the three stubs passed Sonarr’s import checks. manualimport returned rejections: [] for E01 and E11, because qBittorrent writes pieces out of order and the Matroska header survived: ffprobe read a full 25-minute runtime off a file that was 87% holes. Only E10, whose header landed in the missing range, failed loudly. If I had hand-imported that folder I would have shipped two minutes of playable garbage into the library and Sonarr would have considered the episode done.
Live testing caught what the unit tests could not. Running the new report against the real stack surfaced jq: Argument list too long — a 1000-record history page passed as --argjson blows argv, and the caller reads that as an unreachable service. Switched to --slurpfile. The fixtures were too small to ever hit it.
Also worth noting: the user’s manual search hadn’t actually come back empty. It found 10 releases and parked them as pending (reason=2, Fallback) because two Torznab indexers errored mid-search, so Sonarr held the results rather than grabbing off an incomplete set. The queue renders that as status: delay, which reads like nothing happened.
Related
- Media Stack IaC Declarative Config Evaluation
- qBittorrent Force Resume Bypasses Share Limits — the vault’s other recorded download-orphaning mechanism; unrelated cause, same symptom class
- Homelab Services Architecture
- Persist qBittorrent UPnP Port Mapping in Media Stack
- Sibling incidents in the same laptop-sleep family: 2026-08-26 The Outage Was Thirty Seconds, The Damage Was Twelve Hours, 2026-08-31 The 429 Was Never a Rate Limit, 2026-07-16 Docker Services Down After Docker Desktop Restart
The review round found more than the build did
Five follow-up commits on PR #63 after CI’s claude-review pass, and the pattern in them is worth keeping: every real bug was a silent pass, and half of them were only visible against the live stack.
- Bundled retry.
search_orphaned_grabsdid Sonarr and Radarr in one function thatretry_searchwrapped as a unit, so a Radarr blip re-ran the whole thing and re-submitted theEpisodeSearchbatches Sonarr had already accepted. That is the exact anti-pattern this repo already fixed once, for the lookback searches, in PR #61. Adding a third search reintroduced it one level down. - Truncation, twice.
/wanted/missingand then history: both read as a single 1000-row page, and in both cases the row that falls off the end is the oldest one, i.e. the back-catalog grab the whole feature exists to find. “Truncation can only hide, never invent” was true and still not good enough. - The false positive I would never have found by reading. Running the report live, the Docker daemon happened to restart mid-session, and the sweep confidently reported three orphans qBittorrent was actively downloading.
ORPHAN_GRAB_MIN_AGEguards a fresh grab; nothing guarded a fresh Sonarr process, which rebuilds its queue from the client on a ~1 minute cycle and reports an empty queue until it does. And a daemon restart is precisely whenmissing-searchruns its startup cycle, so that was the common path, not a corner case. Fixed withORPHAN_GRAB_SERVICE_GRACE. - Two more silent stops, both found by reading the failure paths rather than the happy path:
jq -rwithout-nblocking on stdin forever, and< <(...)discarding the producer’s exit status so a jq error read as “no orphans found”.
The through-line: a feature whose entire thesis is don’t degrade silently kept shipping code that degraded silently. The fail-closed posture has to be checked at every read, not asserted once in the design.
One thing checked rather than assumed, which the docs now record: the *arrs honour pageSize exactly instead of capping it server-side (Sonarr 4.0.19.2979, 1000 → 1000 and 2000 → 2000 of 2240 rows). The “short page means the end” test depends on that.
Final shape: 1035 lines across 9 files, 38 + 72 unit cases behind two flake checks, all four CI checks green, review converged with no requested changes.
Landed
Merged as ed8d4eb on 2026-09-10 — 19 commits squashed, 1385 lines, 9 files, 61 + 72 unit cases behind two flake checks.
Nineteen review rounds. The last three found only things they simultaneously called fine, which is the signal to stop. Three of the later rounds found things that genuinely mattered, and all three were the same shape as the original bug:
- Settling keyed on
downloadIdalone. One download routinely covers many episodes — this library has packs of 11, 19 and 22, each with an import row per episode — so a pack that imported 11 of 14 and then lost its torrent marked the other 3 settled. The incident that started all this happened to be separate single-episode torrents, which is the only reason it didn’t surface immediately. - A false positive the fix creates by succeeding. Nothing settles a vanished grab, so its history row lives forever; once the sweep re-grabs the episode the replacement has a different download id, and the stale row re-flags the same episode every cycle until the replacement imports.
- My own CHANGELOG was wrong, and it was the claim that mattered operationally: the qBittorrent settings do not auto-apply.
seedSnippetonly rewrites a config that is absent, forced, or embeds a re-minted key — andqBittorrent.confembeds no key. Verified against the module and against the live host, which had neither new setting.
Worth carrying forward: the review rounds that found real bugs were the ones where I went and checked something against the live stack instead of reasoning about it. The pageSize behaviour, the season-pack import rows, the queue’s totalRecords, the cross-service query params, the ~4ms lookup — each of those either confirmed an assumption or killed one, and the two that killed one were both real bugs. Static reading found the silent-stop paths; only execution found the restart-race false positive.
Cleanup done: four orphaned sparse stubs deleted (one Ramparts, three One Punch Man from an older instance of the same failure). Two Reacher files remain, but those are live torrents at 100% that Sonarr refuses to import because the releases carry .exe / .zipx extensions — a separate problem.
Still to do on the host: just deploy for the sweep, and one forceConfig = true switch to actually pick up the qBittorrent settings.