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
41
internal/engine/integrity.go
Normal file
41
internal/engine/integrity.go
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
package engine
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// IntegrityError marks a finished download whose bytes don't match what was
|
||||
// expected: a truncated / short file, a checksum/par2 failure, or an on-disk
|
||||
// size below the advertised length. It is DISTINCT from a transport failure
|
||||
// (network drop, dead tracker) — on an IntegrityError the manager re-downloads
|
||||
// the SAME source a bounded number of times (a fresh, clean-start attempt), and
|
||||
// only after exhausting the retries surfaces the task as damaged. This is the
|
||||
// cross-backend safety net that guarantees "completed" never means a corrupt
|
||||
// file (incident: 2026-06-15 debrid NFS write-back truncation — a 20 MB stub of
|
||||
// a 394 MB file was marked completed because nothing re-checked the on-disk size
|
||||
// after the page-cache write-back silently dropped ~374 MB).
|
||||
type IntegrityError struct {
|
||||
// Reason is a stable short code surfaced to the web / logs:
|
||||
// "truncated", "size_mismatch", "empty", "par2_failed", "flush_failed".
|
||||
Reason string
|
||||
Message string // human-readable detail
|
||||
}
|
||||
|
||||
func (e *IntegrityError) Error() string {
|
||||
if e.Message != "" {
|
||||
return e.Message
|
||||
}
|
||||
return "integrity check failed: " + e.Reason
|
||||
}
|
||||
|
||||
// IsIntegrity reports whether err is (or wraps) an IntegrityError.
|
||||
func IsIntegrity(err error) bool {
|
||||
var ie *IntegrityError
|
||||
return errors.As(err, &ie)
|
||||
}
|
||||
|
||||
// integrityErr builds an IntegrityError with a printf-style message.
|
||||
func integrityErr(reason, format string, args ...any) *IntegrityError {
|
||||
return &IntegrityError{Reason: reason, Message: fmt.Sprintf(format, args...)}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue