fix(scheduler): move the single-run claim into _job (cover both paths)

The atomic claim added in trigger_job only covered the manual path; a scheduled
fire sets running via _job without claiming, so a manual trigger racing it could
still double-run, and the claim (set outside _job) could leak if _job threw
before its own cleanup. Move _claim_running to the top of _job — the one point
both the scheduled fire and the manual trigger pass through — and release it in
_job's finally, so exactly one run wins whichever path it comes from and a
failure can never wedge the job "running". trigger_job's _is_running check is now
just a best-effort fast answer for the UI. Unit-test _claim_running (and
is_messageable_user).
This commit is contained in:
2026-07-23 03:50:04 +02:00
parent 733b41fd86
commit e49b77dbcd
3 changed files with 68 additions and 16 deletions
+21 -16
View File
@@ -162,9 +162,15 @@ def _audit_run(db, actor_id, job_id, status, summary, result) -> None:
def _job(name: str, fn) -> None:
# The one place both run paths converge (manual trigger AND scheduled fire), so claim the run
# HERE, atomically: whichever reaches this first wins, the other skips. This is the real
# single-run guard — trigger_job's pre-check is only a fast best-effort answer for the UI.
if not _claim_running(name):
logger.info("job %s already running — skipping this trigger", name)
return
db = SessionLocal()
actor_id = _manual_actor.get() # set only for a manual "run now" trigger
_record(name, running=True, last_started=_now_iso(), last_error=None, progress=None)
_record(name, last_started=_now_iso(), last_error=None, progress=None) # running already claimed
def sink(current, total, phase):
_record(name, progress={"current": current, "total": total, "phase": phase})
@@ -172,7 +178,7 @@ def _job(name: str, fn) -> None:
try:
if is_sync_paused(db):
logger.info("job %s skipped (sync paused)", name)
_record(name, running=False, status="skipped", last_finished=_now_iso(),
_record(name, status="skipped", last_finished=_now_iso(),
last_result="paused", progress=None)
if actor_id is not None:
_notify_done(db, actor_id, name, "skipped", "sync paused")
@@ -182,7 +188,7 @@ def _job(name: str, fn) -> None:
result = fn(db)
summary = _summarize(result)
logger.info("job %s -> %s", name, result)
_record(name, running=False, status="ok", last_finished=_now_iso(),
_record(name, status="ok", last_finished=_now_iso(),
last_result=summary, progress=None)
if actor_id is not None:
_notify_done(db, actor_id, name, "ok", summary)
@@ -191,12 +197,15 @@ def _job(name: str, fn) -> None:
db.rollback()
logger.exception("job %s failed", name)
err = str(exc) or exc.__class__.__name__
_record(name, running=False, status="error", last_finished=_now_iso(),
_record(name, status="error", last_finished=_now_iso(),
last_error=err, progress=None)
if actor_id is not None:
_notify_done(db, actor_id, name, "error", err)
_audit_run(db, actor_id, name, "error", err, None)
finally:
# ALWAYS release the claim, even if the body raised before its own status update — so a
# failure can never wedge the job as permanently "running".
_record(name, running=False)
db.close()
@@ -382,9 +391,9 @@ def _is_running(job_id: str) -> bool:
def _claim_running(job_id: str) -> bool:
"""Atomically claim a job as running: True if it was idle (and marks it running now), False if
it was already running. Holding the lock across BOTH the check and the set closes the TOCTOU
window where two rapid manual triggers each saw 'idle' (the running flag is otherwise only set
later inside the spawned thread) and both started a run."""
it was already running. Holding the lock across BOTH the check and the set is what makes the
single-run guard race-free — called once at the top of _job so a manual trigger and a scheduled
fire (or two rapid triggers) converge here and exactly one wins. Released in _job's finally."""
with _activity_lock:
if _activity.get(job_id, {}).get("running"):
return False
@@ -411,9 +420,10 @@ def trigger_job(job_id: str, actor_id: int | None = None) -> str:
fn = JOB_FUNCS.get(job_id)
if fn is None:
raise KeyError(job_id)
# Atomic claim (not a bare _is_running check) so two rapid clicks can't both start — the loser
# gets "already_running". _job re-affirms running=True and clears it when the run ends.
if not _claim_running(job_id):
# Best-effort fast answer for the UI; the authoritative single-run guard is _job's atomic claim
# (so a manual trigger and a scheduled fire can't both run). A race here just means the loser's
# _job claims, sees it's taken, and skips — no double run.
if _is_running(job_id):
return "already_running"
def run() -> None:
@@ -421,12 +431,7 @@ def trigger_job(job_id: str, actor_id: int | None = None) -> str:
_manual_actor.set(actor_id)
fn()
try:
threading.Thread(target=run, name=f"trigger-{job_id}", daemon=True).start()
except BaseException:
# Never got the worker going — release the claim so the job isn't wedged "running" forever.
_record(job_id, running=False)
raise
threading.Thread(target=run, name=f"trigger-{job_id}", daemon=True).start()
return "started"
+24
View File
@@ -0,0 +1,24 @@
"""_claim_running is the single-run guard that both the scheduled fire and the manual trigger go
through (at the top of _job): holding _activity_lock across the check-and-set means exactly one
caller can hold a job's "running" claim at a time, and it's claimable again once released."""
from app import scheduler
_JOB = "unit_test_claim" # not a real job id — exercises the primitive without touching live jobs
def _release():
scheduler._record(_JOB, running=False)
def test_claim_is_exclusive_until_released():
_release()
# First caller claims it.
assert scheduler._claim_running(_JOB) is True
# A second caller is refused while the first still holds it (the TOCTOU the fix closes).
assert scheduler._claim_running(_JOB) is False
assert scheduler._claim_running(_JOB) is False
# _job's finally releases the claim...
_release()
# ...and now it can be claimed again.
assert scheduler._claim_running(_JOB) is True
_release()
+23
View File
@@ -0,0 +1,23 @@
"""is_messageable_user — the "real, active, non-suspended human" predicate reused by the messaging
directory and the download share endpoints. A row must be non-demo AND active AND not-suspended."""
from types import SimpleNamespace
from app.userscope import is_messageable_user
def _user(is_demo=False, is_active=True, is_suspended=False):
return SimpleNamespace(is_demo=is_demo, is_active=is_active, is_suspended=is_suspended)
def test_a_real_active_human_is_messageable():
assert is_messageable_user(_user()) is True
def test_none_is_not_messageable():
assert is_messageable_user(None) is False
def test_demo_suspended_or_inactive_are_not_messageable():
assert is_messageable_user(_user(is_demo=True)) is False
assert is_messageable_user(_user(is_suspended=True)) is False
assert is_messageable_user(_user(is_active=False)) is False