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:
+21
-16
@@ -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"
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user