package library import ( "context" "errors" "fmt" "log/slog" "time" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" "git.fabledsword.com/bvandeusen/minstrel/internal/coverart" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" ) // StuckScanThreshold is the age past which an in-flight scan_runs row is // reaped on the next TryStartScan attempt. Real scans on realistic libraries // finish well within this; the threshold is set far above legitimate scan // duration so a normally-running scan is never reaped accidentally. const StuckScanThreshold = 1 * time.Hour // TryStartScan attempts to start a new scan, with built-in handling for // stuck/stale in-flight rows. // // Behavior: // - No in-flight row → starts RunScan in a detached goroutine; returns (true, nil, nil). // - In-flight row, started_at > StuckScanThreshold ago → reaps via FinishScanRun // with error_message="reaped (stale)", then starts new scan; returns (true, nil, nil). // - In-flight row, started_at within threshold → returns (false, &row, nil); caller // decides what to do (HTTP handler returns 409; scheduler logs skip). // - DB error during the in-flight check or reap → returns (false, nil, err). func TryStartScan(ctx context.Context, pool *pgxpool.Pool, scanner *Scanner, enricher *coverart.Enricher, logger *slog.Logger, cfg RunScanConfig, ) (started bool, existing *dbq.GetInFlightScanRunRow, err error) { q := dbq.New(pool) row, qerr := q.GetInFlightScanRun(ctx) if qerr == nil { // Found an in-flight row. Reap if stale, else skip. age := time.Since(row.StartedAt.Time) if age <= StuckScanThreshold { return false, &row, nil } logger.Warn("reaping stale in-flight scan", "id", row.ID, "started_at", row.StartedAt.Time, "age", age) if rerr := q.FinishScanRun(ctx, dbq.FinishScanRunParams{ ID: row.ID, Column2: "reaped (stale)", }); rerr != nil { return false, nil, fmt.Errorf("reap stale scan: %w", rerr) } // Fall through to start a new scan. } else if !errors.Is(qerr, pgx.ErrNoRows) { return false, nil, fmt.Errorf("in-flight check: %w", qerr) } // No in-flight (or just reaped). Start RunScan detached. go func() { if _, runErr := RunScan(ctx, pool, scanner, enricher, logger, cfg); runErr != nil { logger.Warn("scan run failed", "err", runErr) } }() return true, nil, nil }