Files
silo-server/internal/scanqueue/service.go
T
Silo Server Migration a05a0d26a2 refactor(scantrigger): drop redundant Target.LibraryID field
- Read library ID from Target.Folder.ID everywhere
- Guard scan queue enqueue against nil Folder
- Simplify admin API key auth error plumbing
2026-05-25 12:11:56 -04:00

552 lines
14 KiB
Go

package scanqueue
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"sync"
"time"
"github.com/Silo-Server/silo-server/internal/catalog"
evt "github.com/Silo-Server/silo-server/internal/events"
"github.com/Silo-Server/silo-server/internal/libraryingest"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/scantrigger"
)
const (
defaultPollInterval = 2 * time.Second
defaultHeartbeatInterval = 10 * time.Second
defaultStaleAfter = 2 * time.Minute
)
type folderLoader interface {
GetByID(ctx context.Context, id int) (*models.MediaFolder, error)
}
type libraryIngester interface {
IngestFolder(ctx context.Context, folder *models.MediaFolder) (*libraryingest.Result, error)
IngestSubtree(ctx context.Context, folder *models.MediaFolder, subtreePath string) (*libraryingest.Result, error)
IngestFile(ctx context.Context, folder *models.MediaFolder, filePath string) (*libraryingest.Result, error)
}
type Service struct {
repo *Repository
folders folderLoader
ingester libraryIngester
eventsHub *evt.Hub
appCtx context.Context
maxConcurrentLibraries int
maxConcurrentScoped int
pollInterval time.Duration
heartbeatInterval time.Duration
staleAfter time.Duration
stop chan struct{}
stopOnce sync.Once
runningMu sync.Mutex
runningCancels map[string]runningCancel
}
type runningCancel struct {
libraryID int
cancel context.CancelFunc
}
func NewService(
repo *Repository,
folders folderLoader,
ingester libraryIngester,
eventsHub *evt.Hub,
appCtx context.Context,
maxConcurrentLibraries int,
maxConcurrentScoped int,
) *Service {
if maxConcurrentLibraries < 1 {
maxConcurrentLibraries = 1
}
if maxConcurrentScoped < 1 {
maxConcurrentScoped = 1
}
if appCtx == nil {
appCtx = context.Background()
}
return &Service{
repo: repo,
folders: folders,
ingester: ingester,
eventsHub: eventsHub,
appCtx: appCtx,
maxConcurrentLibraries: maxConcurrentLibraries,
maxConcurrentScoped: maxConcurrentScoped,
pollInterval: defaultPollInterval,
heartbeatInterval: defaultHeartbeatInterval,
staleAfter: defaultStaleAfter,
stop: make(chan struct{}),
runningCancels: make(map[string]runningCancel),
}
}
func (s *Service) Start() {
if s == nil || s.repo == nil || s.folders == nil || s.ingester == nil {
return
}
go s.maintenanceLoop()
workerCount := s.maxConcurrentLibraries + s.maxConcurrentScoped
for i := 0; i < workerCount; i++ {
go s.workerLoop()
}
}
func (s *Service) Stop() {
if s == nil {
return
}
s.stopOnce.Do(func() {
close(s.stop)
})
}
func (s *Service) EnqueueLibraryScan(ctx context.Context, folderID int, trigger string) (bool, error) {
return s.EnqueueScan(ctx, folderID, ModeLibrary, "", trigger)
}
func (s *Service) EnqueueScan(ctx context.Context, folderID int, mode, path, trigger string) (bool, error) {
if s == nil || s.repo == nil {
return false, fmt.Errorf("scan queue is not configured")
}
run, created, err := s.repo.Create(ctx, CreateInput{
LibraryID: folderID,
Mode: mode,
Path: path,
Trigger: trigger,
})
if err != nil {
return false, err
}
if created {
s.publish(ctx, "scan.accepted", run)
}
return created, nil
}
func (s *Service) EnqueueScans(ctx context.Context, targets []scantrigger.Target) error {
if s == nil || s.repo == nil {
return fmt.Errorf("scan queue is not configured")
}
inputs := make([]CreateInput, 0, len(targets))
for _, target := range targets {
if target.Folder == nil {
return fmt.Errorf("scan queue: target is missing folder")
}
inputs = append(inputs, CreateInput{
LibraryID: target.Folder.ID,
Mode: target.Mode,
Path: target.Path,
Trigger: target.Trigger,
})
}
runs, created, err := s.repo.CreateBatch(ctx, inputs)
if err != nil {
return err
}
for i, run := range runs {
if i < len(created) && created[i] {
s.publish(ctx, "scan.accepted", run)
}
}
return nil
}
func (s *Service) CancelAcceptedByLibrary(ctx context.Context, libraryID int) (int, error) {
if s == nil || s.repo == nil {
return 0, nil
}
runs, err := s.repo.CancelAcceptedByLibrary(ctx, libraryID)
if err != nil {
return 0, err
}
for _, run := range runs {
s.publish(ctx, "scan.cancelled", run)
}
return len(runs), nil
}
func (s *Service) CancelByLibrary(ctx context.Context, libraryID int) (int, error) {
if s == nil || s.repo == nil {
return 0, nil
}
cancelled, err := s.CancelAcceptedByLibrary(ctx, libraryID)
if err != nil {
return 0, err
}
s.runningMu.Lock()
for _, running := range s.runningCancels {
if running.libraryID != libraryID || running.cancel == nil {
continue
}
running.cancel()
}
s.runningMu.Unlock()
activeRuns, err := s.repo.ListActive(ctx)
if err != nil {
return cancelled, err
}
for _, run := range activeRuns {
if run == nil || run.MediaFolderID != libraryID {
continue
}
_, changed, err := s.repo.MarkCancelled(ctx, run.ID)
if err != nil {
return cancelled, err
}
if changed {
cancelled++
}
}
return cancelled, nil
}
func (s *Service) ListActive(ctx context.Context) ([]evt.ScanRun, error) {
if s == nil || s.repo == nil {
return []evt.ScanRun{}, nil
}
rows, err := s.repo.ListActive(ctx)
if err != nil {
return nil, err
}
runs := make([]evt.ScanRun, 0, len(rows))
for _, row := range rows {
runs = append(runs, toEventRun(row))
}
return runs, nil
}
func (s *Service) maintenanceLoop() {
s.requeueStale()
ticker := time.NewTicker(s.heartbeatInterval)
defer ticker.Stop()
for {
select {
case <-s.stop:
return
case <-s.appCtx.Done():
return
case <-ticker.C:
s.requeueStale()
}
}
}
func (s *Service) requeueStale() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
requeued, err := s.repo.RequeueStaleRunning(ctx, time.Now().UTC().Add(-s.staleAfter))
if err != nil {
slog.Warn("scan queue: failed to requeue stale runs", "error", err)
return
}
if requeued > 0 {
slog.Info("scan queue: requeued stale runs", "count", requeued)
}
}
func (s *Service) workerLoop() {
for {
select {
case <-s.stop:
return
case <-s.appCtx.Done():
return
default:
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
run, err := s.repo.ClaimNextAccepted(ctx, s.maxConcurrentLibraries, s.maxConcurrentScoped)
cancel()
if err != nil {
slog.Warn("scan queue: failed to claim run", "error", err)
s.wait()
continue
}
if run == nil {
s.wait()
continue
}
s.publish(context.Background(), "scan.started", run)
s.process(run)
}
}
func (s *Service) wait() {
timer := time.NewTimer(s.pollInterval)
defer timer.Stop()
select {
case <-s.stop:
case <-s.appCtx.Done():
case <-timer.C:
}
}
func (s *Service) process(run *models.ScanRun) {
if run == nil {
return
}
ctx, cancel := context.WithCancel(s.appCtx)
defer cancel()
s.trackRunning(run.ID, run.MediaFolderID, cancel)
defer s.untrackRunning(run.ID)
progressReporter := newScanProgressReporter(s, run)
ctx = libraryingest.WithProgressReporter(ctx, progressReporter.Report)
heartbeatStop := make(chan struct{})
go s.heartbeatLoop(ctx, run.ID, heartbeatStop)
defer close(heartbeatStop)
folder, err := s.folders.GetByID(ctx, run.MediaFolderID)
switch {
case errors.Is(err, catalog.ErrFolderNotFound):
s.cancelRun(run.ID)
return
case err != nil:
s.failRun(run.ID, fmt.Errorf("load library for scan: %w", err))
return
case folder == nil || !folder.Enabled:
s.cancelRun(run.ID)
return
}
var result *libraryingest.Result
switch run.Mode {
case ModeLibrary:
result, err = s.ingester.IngestFolder(ctx, folder)
case ModeSubtree:
result, err = s.ingester.IngestSubtree(ctx, folder, run.Path)
case ModeFile:
result, err = s.ingester.IngestFile(ctx, folder, run.Path)
default:
err = fmt.Errorf("unsupported scan mode %q", run.Mode)
}
switch {
case errors.Is(err, context.Canceled):
s.cancelRun(run.ID)
case err != nil:
s.failRun(run.ID, err)
default:
s.completeRun(run.ID, result)
}
}
type scanProgressReporter struct {
service *Service
run *models.ScanRun
mu sync.Mutex
last evt.ScanRunResult
}
func newScanProgressReporter(service *Service, run *models.ScanRun) *scanProgressReporter {
return &scanProgressReporter{
service: service,
run: run,
}
}
func (r *scanProgressReporter) Report(update libraryingest.ProgressUpdate) {
if r == nil || r.service == nil || r.service.repo == nil || r.run == nil {
return
}
r.mu.Lock()
r.last.New = update.New
r.last.Updated = update.Updated
r.last.Unchanged = update.Unchanged
r.last.Errors = update.Errors
r.last.MatchedFiles = update.MatchedFiles
r.last.RetriedItems = update.RetriedItems
r.last.Phase = update.Phase
r.last.Message = update.Message
r.last.CurrentScope = update.CurrentScope
r.last.TotalFiles = update.TotalFiles
r.last.FilesDiscovered = update.FilesDiscovered
r.last.FilesProcessed = update.FilesProcessed
current := r.last
r.mu.Unlock()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
run, err := r.service.repo.UpdateProgress(ctx, r.run.ID, &current)
cancel()
if err != nil && !errors.Is(err, ErrScanRunNotFound) {
slog.Warn("scan queue: failed to persist scan progress", "scan_id", r.run.ID, "error", err)
return
}
if run != nil {
slog.Info("scan queue: progress",
"scan_id", run.ID,
"library_id", run.MediaFolderID,
"phase", current.Phase,
"message", current.Message,
"processed_files", current.FilesProcessed,
"total_files", current.TotalFiles,
"matched_files", current.MatchedFiles,
"retried_items", current.RetriedItems,
)
r.service.publish(context.Background(), "scan.progress", run)
}
}
func (s *Service) trackRunning(runID string, libraryID int, cancel context.CancelFunc) {
if s == nil || cancel == nil {
return
}
s.runningMu.Lock()
defer s.runningMu.Unlock()
s.runningCancels[runID] = runningCancel{
libraryID: libraryID,
cancel: cancel,
}
}
func (s *Service) untrackRunning(runID string) {
if s == nil {
return
}
s.runningMu.Lock()
defer s.runningMu.Unlock()
delete(s.runningCancels, runID)
}
func (s *Service) heartbeatLoop(ctx context.Context, runID string, stop <-chan struct{}) {
ticker := time.NewTicker(s.heartbeatInterval)
defer ticker.Stop()
for {
select {
case <-stop:
return
case <-ctx.Done():
return
case <-ticker.C:
touchCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
err := s.repo.TouchHeartbeat(touchCtx, runID)
cancel()
if err != nil && !errors.Is(err, ErrScanRunNotFound) {
slog.Warn("scan queue: failed to touch heartbeat", "scan_id", runID, "error", err)
}
}
}
}
func (s *Service) cancelRun(runID string) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
run, changed, err := s.repo.MarkCancelled(ctx, runID)
if err != nil {
slog.Warn("scan queue: failed to mark cancelled", "scan_id", runID, "error", err)
return
}
if changed {
s.publish(context.Background(), "scan.cancelled", run)
}
}
func (s *Service) failRun(runID string, runErr error) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
run, err := s.repo.Fail(ctx, runID, errString(runErr))
if err != nil {
slog.Warn("scan queue: failed to mark failed", "scan_id", runID, "error", err)
return
}
s.publish(context.Background(), "scan.failed", run)
}
func (s *Service) completeRun(runID string, result *libraryingest.Result) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
run, err := s.repo.Complete(ctx, runID, scanResultFromIngest(result))
if err != nil {
slog.Warn("scan queue: failed to mark completed", "scan_id", runID, "error", err)
return
}
s.publish(context.Background(), "scan.completed", run)
}
func (s *Service) publish(ctx context.Context, eventName string, run *models.ScanRun) {
if s == nil || s.eventsHub == nil || run == nil {
return
}
_ = s.eventsHub.PublishJSON(ctx, evt.ChannelScans, eventName, toEventRun(run), evt.PublishOptions{
AdminOnly: true,
})
}
func toEventRun(run *models.ScanRun) evt.ScanRun {
if run == nil {
return evt.ScanRun{}
}
out := evt.ScanRun{
ID: run.ID,
LibraryID: run.MediaFolderID,
Mode: run.Mode,
Path: run.Path,
Trigger: run.Trigger,
Status: run.Status,
StartedAt: run.StartedAt,
CompletedAt: run.CompletedAt,
ErrorMessage: run.ErrorMessage,
}
if len(run.ResultPayload) > 0 && string(run.ResultPayload) != "{}" {
var result evt.ScanRunResult
if err := json.Unmarshal(run.ResultPayload, &result); err == nil {
out.Result = &result
}
}
return out
}
func scanResultFromIngest(result *libraryingest.Result) *evt.ScanRunResult {
if result == nil {
return nil
}
resp := &evt.ScanRunResult{
MatchedFiles: result.MatchedFiles,
RetriedItems: result.RetriedItems,
StillUnmatchedWarnings: result.StillUnmatchedWarnings,
}
if result.Skipped {
resp.Skipped = 1
}
if result.ScanResult != nil {
resp.New = result.ScanResult.New
resp.Updated = result.ScanResult.Updated
resp.Unchanged = result.ScanResult.Unchanged
resp.Missing = result.ScanResult.Missing
resp.FilesDeleted = result.ScanResult.FilesDeleted
resp.MembershipsRemoved = result.ScanResult.MembershipsRemoved
resp.ItemsDeleted = result.ScanResult.ItemsDeleted
resp.Errors = result.ScanResult.Errors
}
return resp
}
func errString(err error) string {
if err == nil {
return ""
}
return err.Error()
}