Both photo sweeps tracked whether they were running in a module-level variable.
Correct on one replica, wrong behind a load balancer: the status poll answers
from whichever process it reaches, so an idle replica reports isRunning false
while another is mid-run, the UI re-enables the button, and the next POST lands
elsewhere and starts a second pass over the whole library. The .whereNull()
guards mean nothing is corrupted; the cost is duplicated S3/NAS I/O and an
operator who cannot tell whether a job is running.
Migration 189 adds one row per job. The claim is a conditional UPDATE whose
affected-row count is the answer — the shape backgroundProcessor already uses
to hand a photo to exactly one worker — so two replicas cannot both match.
The lease is fenced on a per-claim token: taking over a stale claim does not
stop the old runner, so without fencing a superseded runner finishing late
cleared the new owner's flag and overwrote its result. heartbeat() reports
renewal failure and the loops stop on it. Renewal runs on a timer spanning the
claim through release, including the candidate query, because one hung NAS read
can outlast the stale window inside a single iteration.
maintenance_jobs is excluded from .picpeak archives — an archive taken mid-sweep
would otherwise restore a live lease with no runner to release it. The importer
filters the same set, so older archives are skipped too.
Response shape is unchanged, so the frontend needs no change.
Co-authored-by: Paul Nothaft <[email protected]>