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:
|
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()
|
db = SessionLocal()
|
||||||
actor_id = _manual_actor.get() # set only for a manual "run now" trigger
|
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):
|
def sink(current, total, phase):
|
||||||
_record(name, progress={"current": current, "total": total, "phase": phase})
|
_record(name, progress={"current": current, "total": total, "phase": phase})
|
||||||
@@ -172,7 +178,7 @@ def _job(name: str, fn) -> None:
|
|||||||
try:
|
try:
|
||||||
if is_sync_paused(db):
|
if is_sync_paused(db):
|
||||||
logger.info("job %s skipped (sync paused)", name)
|
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)
|
last_result="paused", progress=None)
|
||||||
if actor_id is not None:
|
if actor_id is not None:
|
||||||
_notify_done(db, actor_id, name, "skipped", "sync paused")
|
_notify_done(db, actor_id, name, "skipped", "sync paused")
|
||||||
@@ -182,7 +188,7 @@ def _job(name: str, fn) -> None:
|
|||||||
result = fn(db)
|
result = fn(db)
|
||||||
summary = _summarize(result)
|
summary = _summarize(result)
|
||||||
logger.info("job %s -> %s", name, 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)
|
last_result=summary, progress=None)
|
||||||
if actor_id is not None:
|
if actor_id is not None:
|
||||||
_notify_done(db, actor_id, name, "ok", summary)
|
_notify_done(db, actor_id, name, "ok", summary)
|
||||||
@@ -191,12 +197,15 @@ def _job(name: str, fn) -> None:
|
|||||||
db.rollback()
|
db.rollback()
|
||||||
logger.exception("job %s failed", name)
|
logger.exception("job %s failed", name)
|
||||||
err = str(exc) or exc.__class__.__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)
|
last_error=err, progress=None)
|
||||||
if actor_id is not None:
|
if actor_id is not None:
|
||||||
_notify_done(db, actor_id, name, "error", err)
|
_notify_done(db, actor_id, name, "error", err)
|
||||||
_audit_run(db, actor_id, name, "error", err, None)
|
_audit_run(db, actor_id, name, "error", err, None)
|
||||||
finally:
|
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()
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
@@ -382,9 +391,9 @@ def _is_running(job_id: str) -> bool:
|
|||||||
|
|
||||||
def _claim_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
|
"""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
|
it was already running. Holding the lock across BOTH the check and the set is what makes the
|
||||||
window where two rapid manual triggers each saw 'idle' (the running flag is otherwise only set
|
single-run guard race-free — called once at the top of _job so a manual trigger and a scheduled
|
||||||
later inside the spawned thread) and both started a run."""
|
fire (or two rapid triggers) converge here and exactly one wins. Released in _job's finally."""
|
||||||
with _activity_lock:
|
with _activity_lock:
|
||||||
if _activity.get(job_id, {}).get("running"):
|
if _activity.get(job_id, {}).get("running"):
|
||||||
return False
|
return False
|
||||||
@@ -411,9 +420,10 @@ def trigger_job(job_id: str, actor_id: int | None = None) -> str:
|
|||||||
fn = JOB_FUNCS.get(job_id)
|
fn = JOB_FUNCS.get(job_id)
|
||||||
if fn is None:
|
if fn is None:
|
||||||
raise KeyError(job_id)
|
raise KeyError(job_id)
|
||||||
# Atomic claim (not a bare _is_running check) so two rapid clicks can't both start — the loser
|
# Best-effort fast answer for the UI; the authoritative single-run guard is _job's atomic claim
|
||||||
# gets "already_running". _job re-affirms running=True and clears it when the run ends.
|
# (so a manual trigger and a scheduled fire can't both run). A race here just means the loser's
|
||||||
if not _claim_running(job_id):
|
# _job claims, sees it's taken, and skips — no double run.
|
||||||
|
if _is_running(job_id):
|
||||||
return "already_running"
|
return "already_running"
|
||||||
|
|
||||||
def run() -> None:
|
def run() -> None:
|
||||||
@@ -421,12 +431,7 @@ def trigger_job(job_id: str, actor_id: int | None = None) -> str:
|
|||||||
_manual_actor.set(actor_id)
|
_manual_actor.set(actor_id)
|
||||||
fn()
|
fn()
|
||||||
|
|
||||||
try:
|
threading.Thread(target=run, name=f"trigger-{job_id}", daemon=True).start()
|
||||||
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
|
|
||||||
return "started"
|
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