"""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