97 lines
3.5 KiB
Python
97 lines
3.5 KiB
Python
"""Misfire-backstop grace gate for one-shot cron jobs (#93526).
|
|
|
|
``fire_overdue_jobs`` in ``cron/scheduler_provider.py`` is the hosted-provider
|
|
misfire catch-up: any runnable job whose ``next_run_at`` is older than
|
|
``cron.misfire_grace_minutes`` gets fired locally. Before the fix it applied
|
|
no one-shot grace check, so a stored past-due one-shot bypassed
|
|
ONESHOT_GRACE_SECONDS and fired arbitrarily late after downtime — the exact
|
|
"will never fire" contract violation the due-scan gate (#89571) closes on the
|
|
built-in scheduler path.
|
|
|
|
Pins:
|
|
- one-shot beyond ONESHOT_GRACE_SECONDS -> backstop skips it (no claim)
|
|
- recurring job equally overdue -> backstop still fires it
|
|
"""
|
|
|
|
from datetime import timedelta
|
|
|
|
from cron.jobs import ONESHOT_GRACE_SECONDS, _hermes_now
|
|
from cron.scheduler_provider import fire_overdue_jobs
|
|
|
|
|
|
class _RecordingProvider:
|
|
"""Non-InProcess provider double that records claim attempts."""
|
|
|
|
def __init__(self):
|
|
self.claimed = []
|
|
|
|
def claim_fire(self, job_id):
|
|
self.claimed.append(job_id)
|
|
return None # decline the claim so no thread is spawned
|
|
|
|
def fire_claimed(self, *a, **k): # pragma: no cover - never reached
|
|
raise AssertionError("fire_claimed must not run when claim declined")
|
|
|
|
|
|
def _job(jid, kind, run_at_dt):
|
|
schedule = {"kind": kind}
|
|
if kind == "once":
|
|
schedule["run_at"] = run_at_dt.isoformat()
|
|
else:
|
|
schedule["expr"] = "*/5 * * * *"
|
|
return {
|
|
"id": jid,
|
|
"name": jid,
|
|
"prompt": "x",
|
|
"schedule": schedule,
|
|
"next_run_at": run_at_dt.isoformat(),
|
|
"last_run_at": None,
|
|
"enabled": True,
|
|
"state": "scheduled",
|
|
"repeat": {"times": 1 if kind == "once" else None, "completed": 0},
|
|
"deliver": "local",
|
|
}
|
|
|
|
|
|
def _run_backstop(monkeypatch, jobs):
|
|
provider = _RecordingProvider()
|
|
monkeypatch.setattr("cron.jobs.load_jobs", lambda: jobs)
|
|
monkeypatch.setattr("cron.jobs.is_job_runnable", lambda j: True)
|
|
monkeypatch.setattr(
|
|
"cron.scheduler_provider._misfire_grace_minutes", lambda: 5.0
|
|
)
|
|
fired = fire_overdue_jobs(provider)
|
|
return provider, fired
|
|
|
|
|
|
class TestMisfireBackstopOneShotGrace:
|
|
def test_stale_oneshot_beyond_grace_is_not_fired(self, monkeypatch):
|
|
now = _hermes_now()
|
|
stale = _job("stale-once", "once", now - timedelta(hours=2))
|
|
provider, fired = _run_backstop(monkeypatch, [stale])
|
|
assert fired == 0
|
|
assert provider.claimed == []
|
|
|
|
def test_overdue_recurring_job_still_fires(self, monkeypatch):
|
|
now = _hermes_now()
|
|
overdue = _job("overdue-cron", "cron", now - timedelta(hours=2))
|
|
provider, fired = _run_backstop(monkeypatch, [overdue])
|
|
assert provider.claimed == ["overdue-cron"]
|
|
|
|
def test_oneshot_within_oneshot_grace_but_past_misfire_grace(
|
|
self, monkeypatch
|
|
):
|
|
# misfire grace patched to ~0 so a 60s-late one-shot is "overdue" for
|
|
# the backstop but still inside ONESHOT_GRACE_SECONDS -> must fire.
|
|
assert ONESHOT_GRACE_SECONDS > 60
|
|
now = _hermes_now()
|
|
recent = _job("recent-once", "once", now - timedelta(seconds=60))
|
|
provider = _RecordingProvider()
|
|
monkeypatch.setattr("cron.jobs.load_jobs", lambda: [recent])
|
|
monkeypatch.setattr("cron.jobs.is_job_runnable", lambda j: True)
|
|
monkeypatch.setattr(
|
|
"cron.scheduler_provider._misfire_grace_minutes", lambda: 0.5
|
|
)
|
|
fire_overdue_jobs(provider)
|
|
assert provider.claimed == ["recent-once"]
|