tick() advances a recurring job's next_run_at BEFORE dispatch so a crash mid-run cannot re-fire it on every restart (at-most-once). That leaves a window — advance persisted, fire claim not yet taken — in which the process dying (interpreter already finalizing, executor refusing new futures, SIGKILL, the Desktop idle-exit from #107485) loses the occurrence silently: the restarted scan sees only the future next_run_at, writes no execution row and no log line, and a daily job skips a day. Contract (documented in cron-internals.md): every recurring occurrence is accounted for — it runs once, or its skip is logged with a reason. - The due scan stamps `pending_slot = {scheduled_at, at, by}` in the same save that records `last_dispatch`; claim_job_for_fire and mark_job_run clear it, and an explicit schedule / next_run_at / enabled / state rewrite (edit, pause, resume, run-now) drops it. - A later scan that finds the stamp with a provably gone owner (this process and the job not in its running set, or another owner past the fire-claim lease / dead pid) restores scheduled_at as next_run_at ONCE and logs a WARNING (cron/occurrences.py::unclaimed_pending_slot). The restored instant then meets the ordinary policy — completed_occurrence() blocks a second fire of a slot that already ran, the grace window classifies it late, past-grace collapses the backlog into one run, cron.catch_up_missed: false skips it with a logged reason. Never a replay of N slots. Same store fields on both topologies: a standalone `hermes -p X gateway run` and a profile served by the default multiplexer evaluate the identical record.
86 lines
3.7 KiB
Python
86 lines
3.7 KiB
Python
"""Exact scheduled identities, independent of mutable jobs.json dispatch stamps."""
|
|
from datetime import datetime, timezone
|
|
import logging
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def scheduled_instant(value):
|
|
"""Canonicalize aware instants; legacy/ambiguous values carry no exact identity."""
|
|
if not isinstance(value, str):
|
|
return None
|
|
try:
|
|
instant = datetime.fromisoformat(value)
|
|
if instant.tzinfo is None:
|
|
return None
|
|
return instant.astimezone(timezone.utc).isoformat()
|
|
except ValueError:
|
|
return None
|
|
|
|
|
|
def completed_occurrence(job, instant):
|
|
"""Unknown/failed/pruned attempts cannot prove completion: keep them eligible."""
|
|
from cron.executions import _transaction
|
|
|
|
instant = scheduled_instant(instant)
|
|
if instant is None:
|
|
return False
|
|
try:
|
|
with _transaction() as conn:
|
|
return conn.execute(
|
|
"SELECT 1 FROM executions WHERE job_id=? AND scheduled_instant=? "
|
|
"AND status='completed' LIMIT 1", (str(job['id']), instant)
|
|
).fetchone() is not None
|
|
except Exception:
|
|
logger.warning("Cannot check completed occurrence for job %s", job['id'], exc_info=True)
|
|
return False
|
|
|
|
|
|
# --- Pending slot: the occurrence a tick took off the schedule but has not yet claimed ---
|
|
#
|
|
# The tick advances a recurring job's ``next_run_at`` BEFORE dispatch (at-most-once: a crash
|
|
# mid-run must not re-fire on every restart). That leaves a window — advance done, fire claim
|
|
# not yet taken (interpreter finalizing, executor refusing work, process killed) — in which the
|
|
# process exiting loses the occurrence silently: the restarted scan sees a future ``next_run_at``
|
|
# and nothing ever ran (#107485). ``pending_slot`` is the durable record of that window: the due
|
|
# scan stamps it with the exact stored instant plus the stamping owner, ``claim_job_for_fire``
|
|
# (the point after which side effects may exist) clears it, and any explicit rewrite of the
|
|
# schedule drops it. A slot still pending once its owner is provably gone (or its lease has
|
|
# expired) was never claimed, so it is restored ONCE as the due instant and then flows through
|
|
# the ordinary late / fast-forward / ``cron.catch_up_missed`` policy — never N replays.
|
|
|
|
def pending_slot_stamp(next_run, now):
|
|
"""Store value for a recurring occurrence about to be handed to the dispatcher."""
|
|
from cron.jobs import _machine_id
|
|
|
|
return {"scheduled_at": next_run, "at": now.isoformat(), "by": _machine_id()}
|
|
|
|
|
|
def unclaimed_pending_slot(job, now):
|
|
"""Stored instant of a slot the dispatcher never claimed, or None.
|
|
|
|
None for non-recurring jobs and malformed stamps (never a fire) and for a job still in this
|
|
process's running set (its queued worker will claim and clear the slot itself). A stamp by
|
|
THIS process on a job not running here is orphaned (dispatch refused). A stamp by another
|
|
process is honoured while that owner may still be alive within the fire-claim lease — a
|
|
second live gateway on the same store is mid-dispatch, not dead."""
|
|
from cron.jobs import (
|
|
FIRE_CLAIM_TTL_SECONDS, _claim_is_live, _job_running_in_this_process, _machine_id,
|
|
)
|
|
|
|
pending = job.get("pending_slot")
|
|
if not isinstance(pending, dict):
|
|
return None
|
|
slot = pending.get("scheduled_at")
|
|
if job.get("schedule", {}).get("kind") not in {"cron", "interval"} or not isinstance(slot, str):
|
|
return None
|
|
try:
|
|
datetime.fromisoformat(slot)
|
|
except ValueError:
|
|
return None
|
|
if _job_running_in_this_process(str(job.get("id", ""))):
|
|
return None
|
|
if pending.get("by") != _machine_id() and _claim_is_live(pending, now, FIRE_CLAIM_TTL_SECONDS):
|
|
return None
|
|
return slot
|