fix(engine): cross-backend integrity guard with retry-then-damaged
A truncated debrid download (in-memory byte counter hit 100% while the
NFS write-back silently dropped most of the bytes) was marked completed.
The 1.1.6 fsync fix closed the debrid-specific hole; this generalizes the
guarantee so "completed" never means a corrupt file on ANY backend.
- IntegrityError + bounded retry: on a corrupt/short result the manager
re-downloads the same source up to 3x (clean start), then surfaces the
task as damaged ("corrupt download:" prefix) instead of completing it.
- verify (size mismatch / empty), debrid (incomplete / post-write / flush),
torrent (BytesMissing), usenet (par2 unrepairable / repair-failed) all
classify integrity failures so they route through the retry/damaged path.
- scanner: a file ffprobe can't read is emitted as a damaged library_item
(reason "unreadable") instead of being silently dropped from the sync.
- tests: manager retry-then-success + retry-exhausted-then-damaged,
verifying->resolving transition, damaged sync item.
This commit is contained in:
parent
271413e0f9
commit
a5f3f0914a
13 changed files with 400 additions and 91 deletions
128
internal/engine/manager_integrity_test.go
Normal file
128
internal/engine/manager_integrity_test.go
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/torrentclaw/unarr/internal/agent"
|
||||
)
|
||||
|
||||
// truncatingMockDownloader writes a SHORT file (failing the on-disk verify) until
|
||||
// goodOnAttempt, then writes a full file. reportedSize is what each Result claims,
|
||||
// so verify() compares the advertised size against the (initially truncated) bytes
|
||||
// on disk — the exact shape of the 2026-06-15 debrid NFS truncation.
|
||||
type truncatingMockDownloader struct {
|
||||
dir string
|
||||
reportedSize int64
|
||||
goodOnAttempt int // 1-based attempt that finally writes a full file; 0 = never
|
||||
callCount atomic.Int32
|
||||
}
|
||||
|
||||
func (m *truncatingMockDownloader) Method() DownloadMethod { return MethodTorrent }
|
||||
func (m *truncatingMockDownloader) Available(_ context.Context, _ *Task) (bool, error) {
|
||||
return true, nil
|
||||
}
|
||||
func (m *truncatingMockDownloader) Download(_ context.Context, _ *Task, _ string, _ chan<- Progress) (*Result, error) {
|
||||
n := int(m.callCount.Add(1))
|
||||
path := filepath.Join(m.dir, "movie.mkv")
|
||||
size := int64(10) // truncated stub
|
||||
if m.goodOnAttempt > 0 && n >= m.goodOnAttempt {
|
||||
size = m.reportedSize
|
||||
}
|
||||
if err := os.WriteFile(path, make([]byte, size), 0o644); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Result{FilePath: path, FileName: "movie.mkv", Method: MethodTorrent, Size: m.reportedSize}, nil
|
||||
}
|
||||
func (m *truncatingMockDownloader) Pause(_ string) error { return nil }
|
||||
func (m *truncatingMockDownloader) Cancel(_ string) error { return nil }
|
||||
func (m *truncatingMockDownloader) Shutdown(_ context.Context) error { return nil }
|
||||
|
||||
// captureReporter builds a ProgressReporter over a mockStatusReporter we keep a
|
||||
// handle to, so the test can read the final reported StatusUpdate.
|
||||
func captureReporter() (*ProgressReporter, *mockStatusReporter) {
|
||||
reporter := &mockStatusReporter{}
|
||||
return &ProgressReporter{
|
||||
reporter: reporter,
|
||||
interval: 50 * time.Millisecond,
|
||||
latest: make(map[string]*Task),
|
||||
lastReported: make(map[string]TaskStatus),
|
||||
}, reporter
|
||||
}
|
||||
|
||||
func terminalUpdate(t *testing.T, r *mockStatusReporter, taskID string) agent.StatusUpdate {
|
||||
t.Helper()
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
for i := len(r.calls) - 1; i >= 0; i-- {
|
||||
c := r.calls[i]
|
||||
if c.TaskID == taskID && (c.Status == "completed" || c.Status == "failed") {
|
||||
return c
|
||||
}
|
||||
}
|
||||
t.Fatalf("no terminal (completed/failed) status update for %s", taskID)
|
||||
return agent.StatusUpdate{}
|
||||
}
|
||||
|
||||
// A truncated download is re-tried clean and, once it lands intact, completes —
|
||||
// "completed" is never reported for the corrupt attempt.
|
||||
func TestManagerPipeline_IntegrityRetry_ThenSucceeds(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
pr, reporter := captureReporter()
|
||||
dl := &truncatingMockDownloader{dir: dir, reportedSize: 10000, goodOnAttempt: 2}
|
||||
|
||||
mgr := NewManager(ManagerConfig{MaxConcurrent: 1, OutputDir: dir}, pr, dl)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
go pr.Run(ctx)
|
||||
|
||||
const taskID = "integrity-retry-ok-123456"
|
||||
mgr.Submit(ctx, agent.Task{
|
||||
ID: taskID, InfoHash: "abc123def456abc123def456abc123def456abc1",
|
||||
Title: "Retry Test", PreferredMethod: "torrent",
|
||||
})
|
||||
mgr.Wait()
|
||||
|
||||
if got := dl.callCount.Load(); got != 2 {
|
||||
t.Errorf("download attempts = %d, want 2 (1 truncated + 1 clean)", got)
|
||||
}
|
||||
if u := terminalUpdate(t, reporter, taskID); u.Status != "completed" {
|
||||
t.Errorf("final status = %q (%s), want completed", u.Status, u.ErrorMessage)
|
||||
}
|
||||
}
|
||||
|
||||
// A persistently-truncated download exhausts the bounded retries and is surfaced
|
||||
// as damaged (failed + the stable corrupt-download marker), never completed.
|
||||
func TestManagerPipeline_IntegrityRetry_ExhaustsThenDamaged(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
pr, reporter := captureReporter()
|
||||
dl := &truncatingMockDownloader{dir: dir, reportedSize: 10000, goodOnAttempt: 0}
|
||||
|
||||
mgr := NewManager(ManagerConfig{MaxConcurrent: 1, OutputDir: dir}, pr, dl)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
go pr.Run(ctx)
|
||||
|
||||
const taskID = "integrity-retry-bad-123456"
|
||||
mgr.Submit(ctx, agent.Task{
|
||||
ID: taskID, InfoHash: "abc123def456abc123def456abc123def456abc1",
|
||||
Title: "Damaged Test", PreferredMethod: "torrent",
|
||||
})
|
||||
mgr.Wait()
|
||||
|
||||
if got := dl.callCount.Load(); got != 3 {
|
||||
t.Errorf("download attempts = %d, want 3 (bounded retries)", got)
|
||||
}
|
||||
u := terminalUpdate(t, reporter, taskID)
|
||||
if u.Status != "failed" {
|
||||
t.Fatalf("final status = %q, want failed", u.Status)
|
||||
}
|
||||
if !strings.HasPrefix(u.ErrorMessage, damagedErrorPrefix) {
|
||||
t.Errorf("error message = %q, want prefix %q", u.ErrorMessage, damagedErrorPrefix)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue