Files
minstrel/internal/library/trigger.go
T
bvandeusen 8784e938bd feat(server/m7-recurring-scans): TryStartScan helper + reaper
Extracts the in-flight check + scan-start logic from handleTriggerScan
into a shared library.TryStartScan helper used by both manual and
(future) scheduled paths. Folds in the lazy 1-hour stuck-scan
reaper: in-flight rows older than StuckScanThreshold get
FinishScanRun-ed with error_message="reaped (stale)" and the new
scan proceeds.

handleTriggerScan becomes a thin wrapper preserving the same
external HTTP behavior (202/409/500). Operators no longer need to
manually clear stuck rows after a server crash — the next manual
trigger reaps it.

Tests gated on MINSTREL_TEST_DATABASE_URL: no-in-flight starts;
fresh-in-flight skips with row pointer; stale-in-flight reaps
(verified via finished_at + error_message); DB error propagates.
2026-05-06 22:15:19 -04:00

67 lines
2.3 KiB
Go

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 {
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 {
return false, &row, nil
}
} 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
}