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:
2026-05-06 22:15:19 -04:00
parent e27d030e68
commit 8784e938bd
3 changed files with 244 additions and 20 deletions
+14 -20
View File
@@ -74,30 +74,24 @@ type scanTriggerResp struct {
}
// handleTriggerScan implements POST /api/admin/scan/run.
// Returns 202 on success, 409 if a scan is already in flight.
// The scan runs in a background goroutine detached from the request context.
// Returns 202 on success, 409 if a scan is already in flight (and not stale),
// 500 on DB errors. The lazy reaper inside library.TryStartScan handles
// stale (>1h) in-flight rows automatically — the operator no longer needs
// to manually clear stuck rows after a server crash.
func (h *handlers) handleTriggerScan(w http.ResponseWriter, _ *http.Request) {
bgCtx := context.Background()
q := dbq.New(h.pool)
existing, err := q.GetInFlightScanRun(bgCtx)
if err == nil {
started, existing, err := library.TryStartScan(
bgCtx, h.pool, h.scanner, h.coverart,
h.logger.With("source", "manual"), h.scanCfg,
)
if err != nil {
h.logger.Error("admin: trigger scan failed", "err", err)
writeErr(w, http.StatusInternalServerError, "server_error", "trigger failed")
return
}
if !started {
writeJSON(w, http.StatusConflict, scanTriggerResp{ID: uuidToString(existing.ID)})
return
}
if !errors.Is(err, pgx.ErrNoRows) {
h.logger.Error("admin: in-flight check failed", "err", err)
writeErr(w, http.StatusInternalServerError, "server_error", "in-flight check failed")
return
}
// Detached goroutine — must outlive the HTTP request.
go func() {
if _, err := library.RunScan(bgCtx, h.pool, h.scanner, h.coverart,
h.logger.With("component", "scan_run"), h.scanCfg); err != nil {
h.logger.Warn("manual scan run failed", "err", err)
}
}()
writeJSON(w, http.StatusAccepted, scanTriggerResp{})
}