SKYHOUSE.dev Journal

Maintaining the Cloud Fortress

One Stuck Torrent Ate 29 GB of Log and Took the Root Disk With It

Why: Automatic downloads had stopped working and manual browser downloads had gone slow, fragile, and interruption-prone. The symptom pointed at /media/plex2, which had just been through the USB overhaul — so the obvious suspicion was that the storage problem had come back.

It hadn't. plex2 was blameless, and the actual culprit was a single database row that had been wedged since July 18.

1. Ruling out the drive first

Given the history it was worth being thorough before believing anything else. Every storage measurement came back healthy:

Storage, filesystem, and network were all fine. The failure was upstream of all of them.

2. The actual fault: a crash loop at 8 Hz

docker logs rdtclient was full of one repeating exception:

SQLite Error 19: 'UNIQUE constraint failed: Downloads.TorrentId, Downloads.Path'
  at RdtClient.Data.Data.TorrentData.UpdateComplete(...)
  at RdtClient.Service.Services.TorrentRunner.Tick()

56,712 occurrences in two hours — about eight per second, throwing and retrying on the next tick, forever. ⚠️ Corrected 2026-08-04 (while assembling the upstream bug report, from the app's own logs rather than the docker log): the loop throws from two call sites per tick, not one, and the dominant one is not the insert originally described here. Tick():521 → UnrestrictLink → Downloads.UpdatePath (DownloadData.cs:145) is an UPDATE — the unrestrict call returns a path already held by a sibling row of the same torrent — at 1,038 hits, alongside Tick():773 → UpdateComplete (TorrentData.cs:236) at 1,039 in the same window. Detection and remediation are unaffected, since both key on the database state rather than the call site, but the original attribution would have sent a future session hunting the wrong function. Nothing in the download pipeline could progress, and rdtclient.db had not been written since 03:30 that morning because every transaction rolled back.

3. Why a container bug broke the browser

This is the part worth remembering. Each failure wrote a multi-kilobyte stack trace, and it went to two unbounded logs, both on the root disk:

Servicing that, dockerd was burning 136% CPU and driving 987 reads/sec at 122 MB/s against sda. Both docker stats and docker logs timed out at 120 s, which was itself the tell that the daemon was saturated.

Chrome's profile, download history, and the entire desktop session live on sda. So the media drives were idle and fast while anything touching the root disk was fighting the daemon for it. The symptom appeared on plex2; the contention was on sda. A storage-shaped complaint whose cause was three layers away.

4. The one row

The wedge was torrent 02D88676Lost.Girl.2010.Complete.Seasons.1.to.5, added July 18:

Critically, the content had downloaded fine: 135 GB and 115 video files sitting in /media/plex2/TV/Lost Girl (2010), already imported. The recorded paths were https://torbox.app/fakedl/... URLs, so deleting the database record touched neither local files nor the remote.

5. What was done

6. ⚠️ The log cap is only half applied

Worth being precise about, because it is easy to assume otherwise: daemon.json log options apply at container creation, not at start. Verified after the daemon restart — all 28 existing containers still report opts=map[], i.e. no limit. Restarting them does not make them inherit the new default; each must be recreated (docker compose up --force-recreate, or a Portainer stack redeploy) before it is actually protected.

So: anything created from now on is safe, and every container currently running is still capable of doing exactly this again. Recreating 28 containers — including immich's postgres and Home Assistant — is a deliberate operation for a quiet moment, not a drive-by. A lighter interim option is a cron job that truncates any container log over a threshold.

7. Residual

8. Countermeasures (added same day)

First, the finding that shapes everything else: rdt-client cannot be configured out of this. Its DownloadRetryAttempts (3), TorrentRetryAttempts (1), DeleteOnError (0) and TorrentLifetime (0) all govern failed downloads. The wedge is an unhandled DbUpdateException thrown inside the background service before any retry logic applies, so no setting reaches it. General:LogLevel is already Error and these stack traces are Error-level, so the volume can't be reduced without going dark. Detection and mitigation must live outside the container.

Two layers were added, deliberately separated by role:

9. What actually triggered it — not what anyone assumed

Two working assumptions were wrong; the pre-repair backup settled both.

Wrong assumption 1: that finalising by hand caused it. Damien pushed back — the manual copy went to a path rdt-client knew nothing about. He was right. Of 232 Downloads rows, exactly one was incomplete:

Path:        https://torbox.app/fakedl/57169452/47
Added:       2026-07-25 04:05   <- seven days AFTER the torrent (07-18)
RetryCount:  1
DownloadStarted / DownloadFinished / Completed:  all empty

One member failed; rdt-client retried it a week later, leaving one incomplete row. The provider meanwhile reported the torrent 100% complete (RdStatus 4), so UpdateComplete fired and tried to insert a row for index 47 that already existed. Structural, not bad luck — any multi-file torrent with one unfetchable member reproduces it. That makes tv the exposed instance (multi-season packs) and movies far less so.

Wrong assumption 2: that a corrupt file inside a zip was being re-extracted. There was no archive and no unpacking. The torrent held 77 .mp4, 154 .srt, 1 .txt — discrete files, zero archives. Resolving index 47 through Torrents.RdFiles (the provider's file list, stored as JSON) shows the file that took the pipeline down for 16 days was a ~50 KB English subtitle. (The index may be the file's Id or its array position — these differ, as the array is not Id-sorted. Both candidates are .srt files under Subs/, so the conclusion holds either way; the tooling reports both.)

10. Automatic salvage — the behaviour actually wanted

The goal: download everything possible, move what can be moved into position, and say something about the file that failed. Most of that already worked — 231 of 232 files downloaded fine. The broken part was finalisation: the torrent never reaches complete, so FinishedAction never fires and the queue jams behind it.

/home/plex/bin/rdtclient-stuckfiles.py does both jobs. It resolves opaque fakedl indices to real filenames (a failed .srt is a shrug; a failed .mkv wants a Sonarr re-grab), and with --remediate closes stale orphan rows so the torrent finalises and the queue unblocks. The watchdog runs it with salvage on (AUTO_SALVAGE=1) and reports the action over Telegram.

Guards matter, because the pre-wedge shape also matches a torrent legitimately mid-download (provider at 100%, files still arriving):

Tested against a throwaway copy of the pre-repair database through all four paths: dry-run inert, age guard refusing, real salvage closing the orphan (232/232, backup written), clean re-scan afterwards.

One honest limitation: that rdt-client finalises cleanly after the rows close could not be verified without reproducing a live wedge. The database side is proven; the application's reaction is not. If salvage ever fails to unblock things, the fallback is deleting the torrent record outright — what actually worked on 2026-08-03. Every write is backed up, so either path is reversible.

Workflow note. Torrents also exposes IncludeRegex / ExcludeRegex for per-torrent file filtering — useful for pre-emptively skipping a member known to be bad.

The lesson to carry: when a symptom names a disk that has recently been worked on, measure that disk first and then keep going. Here the storage was exonerated in five minutes, and the real cause was a single row of bookkeeping that had been quietly amplifying itself into 29 GB since mid-July.

← Back to Admin Hub