@startuml MonitorAlbum - Async Poll Worker Failures skinparam sequenceMessageAlign center skinparam responseMessageBelowArrow true title MonitorAlbum: Async Download Poll Worker Failures participant "River Queue" as River participant "PollDownloadWorker" as Worker participant "TorrentClient\n(qBittorrent)" as QBit database "PostgreSQL" as DB note over Worker: These occur asynchronously\nafter MonitorAlbum returns.\nClient already received response. == Case 1: qBittorrent unreachable during poll == River -> Worker: Work(PollDownloadArgs) Worker -> QBit: Find(hash) QBit --> Worker: error (connection refused) note right #orange: Logged as error.\nJob rescheduled. Worker -> River: Insert(PollDownloadArgs) note right: Reschedule after check_interval (30s).\nRetries indefinitely until\nqBit becomes available. == Case 2: Torrent disappeared from qBittorrent == River -> Worker: Work(PollDownloadArgs) Worker -> QBit: Find(hash) QBit --> Worker: empty results note right #salmon: Torrent was removed\nfrom qBit externally. Worker -> DB: downloads.SetFailed(id, "torrent not found in client") note right: Download marked as failed.\nNo further polls scheduled.\nNo retry. == Case 3: Torrent in error state == River -> Worker: Work(PollDownloadArgs) Worker -> QBit: Find(hash) QBit --> Worker: TorrentInfo{state: "error"} note right #salmon: qBit reports torrent error.\n(e.g. tracker unreachable,\ncorrupt data, disk full) Worker -> DB: downloads.SetFailed(id, "torrent error state") note right: Download marked as failed.\nNo further polls.\nTorrent remains in qBit. == Case 4: Download completes, but SetCompleted fails == River -> Worker: Work(PollDownloadArgs) Worker -> QBit: Find(hash) QBit --> Worker: TorrentInfo{progress: 1.0, savePath: "/downloads"} Worker -> DB: downloads.SetCompleted(id, "/downloads") DB --> Worker: error (DB connection lost) note right #salmon: Worker returns error.\nRiver will retry the job\n(built-in retry policy).\nDownload stays in\n"downloading" state. == Case 5: File scan fails after completion == River -> Worker: Work(PollDownloadArgs) Worker -> QBit: Find(hash) QBit --> Worker: TorrentInfo{progress: 1.0, path: "/downloads/Album"} Worker -> DB: downloads.SetCompleted(id, "/downloads") DB --> Worker: OK note right #lightgreen: Download marked completed. Worker -> Worker: scanAndHashFiles("/downloads/Album") Worker --> Worker: error (permission denied / path not found) note right #orange: Logged as error.\nDownload IS completed.\nBut download_files NOT populated.\nGetAlbum won't show file info\nfor individual tracks. == Case 6: File persist fails == River -> Worker: Work(PollDownloadArgs) Worker -> QBit: Find(hash) QBit --> Worker: TorrentInfo{progress: 1.0} Worker -> DB: downloads.SetCompleted(id, savePath) Worker -> Worker: scanAndHashFiles → 12 files Worker -> DB: download_files.CreateBatch(files) DB --> Worker: error (duplicate / constraint) note right #orange: Download is completed.\nFiles not persisted.\nNon-fatal: returns nil.\nNo retry. == Case 7: App crash during download (startup recovery) == note over River, Worker: Application restarts.\nRiver picks up persisted jobs. River -> Worker: RecoverOrphanedDownloads() Worker -> DB: downloads.GetActive() DB --> Worker: [Download{state: downloading, hash: ...}] Worker -> River: Insert(PollDownloadArgs, UniqueOpts{ByArgs}) note right #lightgreen: Deduplicated insert.\nIf River job already exists\n→ no duplicate.\nIf job was lost → recovered.\nPolling resumes. @enduml