TV/series full scans (libraries with new or updated items) were recorded as "cancelled" with an empty error message and never completed matching. When the file-walk finishes, the ingest executor waits out a settle window and then calls stopDrainers() to shut down the concurrent match goroutines. That cancels the drainer context while a ProcessBatchByFolderAndPathPrefix call may still be in flight. The drainer treated the resulting context.Canceled as a fatal error: it pushed the error to drainerErrCh and called cancel() on the whole scan context, so scanqueue.process() mapped it to cancelRun(). Large/slow libraries (many series, slow provider lookups) keep a batch in flight continuously, so stopDrainers() almost always landed mid-call and the scan was cancelled; small/fast libraries were usually idle at that instant and completed normally. Treat a cancelled drainer context as a deliberate shutdown: return cleanly without escalating. Genuine external cancellation still reaches the run via the main goroutine's scanCtx checks, so real cancels are not swallowed. Adds a regression test (settle window made injectable) that fails against the old handler with 'concurrent match scope ...: context canceled' and passes with the fix.
83 lines
2.8 KiB
Go
83 lines
2.8 KiB
Go
package libraryingest
|
|
|
|
import (
|
|
"context"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/Silo-Server/silo-server/internal/models"
|
|
"github.com/Silo-Server/silo-server/internal/scanner"
|
|
)
|
|
|
|
// settleBlockingMatcher simulates a TV match drainer whose provider lookup is
|
|
// still in flight when the settle window expires: the batch call blocks until
|
|
// its context is cancelled, then returns context.Canceled — exactly what
|
|
// stopDrainers triggers when it cancels the drainer context.
|
|
type settleBlockingMatcher struct {
|
|
batchCalls atomic.Int64
|
|
}
|
|
|
|
func (m *settleBlockingMatcher) ProcessBatchByFolderAndPathPrefix(ctx context.Context, _ int, _ string, _ time.Time) (int, error) {
|
|
m.batchCalls.Add(1)
|
|
<-ctx.Done()
|
|
return 0, ctx.Err()
|
|
}
|
|
|
|
func (m *settleBlockingMatcher) ProcessAllByFolderAndPathPrefix(context.Context, int, string, time.Time) (int, error) {
|
|
return 0, nil
|
|
}
|
|
|
|
func (m *settleBlockingMatcher) RetryUnmatchedItemsByFolderAndPathPrefix(context.Context, int, string) (int, int, error) {
|
|
return 0, 0, nil
|
|
}
|
|
|
|
// settleStubScanner returns a scan result that triggers the TV settle window
|
|
// (a series library with new items) and accepts the post-match finalize call.
|
|
type settleStubScanner struct {
|
|
result *scanner.ScanResult
|
|
}
|
|
|
|
func (s *settleStubScanner) ScanFolder(context.Context, *models.MediaFolder) (*scanner.ScanResult, error) {
|
|
return s.result, nil
|
|
}
|
|
|
|
func (s *settleStubScanner) ScanSubtree(context.Context, *models.MediaFolder, string) (*scanner.ScanResult, error) {
|
|
return s.result, nil
|
|
}
|
|
|
|
func (s *settleStubScanner) ScanFile(context.Context, string, *models.MediaFolder) error {
|
|
return nil
|
|
}
|
|
|
|
func (s *settleStubScanner) FinalizeVariantsByPathPrefix(context.Context, *models.MediaFolder, string) error {
|
|
return nil
|
|
}
|
|
|
|
// TestIngestFolderCompletesWhenDrainerCanceledMidBatch is a regression test for
|
|
// the settle-window cancellation bug: a TV library full scan was recorded as
|
|
// "cancelled" because a drainer batch in flight when stopDrainers() ran returned
|
|
// context.Canceled, which the drainer escalated as a fatal scan error. The
|
|
// ingest must instead complete normally.
|
|
func TestIngestFolderCompletesWhenDrainerCanceledMidBatch(t *testing.T) {
|
|
matcher := &settleBlockingMatcher{}
|
|
exec := &Executor{
|
|
scanner: &settleStubScanner{result: &scanner.ScanResult{New: 1}},
|
|
matcher: matcher,
|
|
now: time.Now,
|
|
tvDrainSettleWindow: 50 * time.Millisecond,
|
|
}
|
|
folder := &models.MediaFolder{ID: 5, Type: "series", Paths: []string{"/tv"}}
|
|
|
|
result, err := exec.IngestFolder(context.Background(), folder)
|
|
if err != nil {
|
|
t.Fatalf("expected ingest to complete, got error: %v", err)
|
|
}
|
|
if result == nil || result.Skipped {
|
|
t.Fatalf("expected a non-skipped result, got %+v", result)
|
|
}
|
|
if matcher.batchCalls.Load() == 0 {
|
|
t.Fatal("drainer never ran a batch; test did not exercise the settle-window shutdown path")
|
|
}
|
|
}
|