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.
This commit is contained in:
@@ -0,0 +1,66 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user