fix(cron): guard None repeat.completed when recording a job run

`repeat.get("completed", 0)` only falls back to 0 when the KEY is
absent. When the key exists with value None (a job created but never
successfully recorded a run), it returns None, and the following
`completed += 1` raises:

    unsupported operand type(s) for +=: 'NoneType' and 'int'

Effect: the run is never recorded (last_run_at/last_status stay null)
and every fire logs a scheduling error. The job itself executes fine.

Same latent hazard at the `completed < times` comparison, which a None
value would also fail on.

Fix all three sibling sites with `repeat.get("completed") or 0`.

(cherry picked from commit 0c5024de901900a747849ca44750327b56477f3b)
This commit is contained in:
mochamgx
2026-09-25 10:05:02 +08:00
committed by kshitij
parent bece74567f
commit b653fd724a

View File

@@ -2360,7 +2360,7 @@ def _advance_after_run(job: Dict[str, Any], now: str) -> None:
if repeat:
times = repeat.get("times")
finite = times is not None and times > 0
completed = repeat.get("completed", 0)
completed = repeat.get("completed") or 0
# Finite one-shots were pre-claimed by claim_dispatch() (completed already incremented) —
# do not double-count; recurring jobs and direct callers still get the increment.
if not (kind == "once" and finite and completed > 0):
@@ -2541,7 +2541,7 @@ def claim_dispatch(job_id: str) -> bool:
# Recurring jobs use advance_next_run(); no/infinite repeat limit always dispatches.
if job.get("schedule", {}).get("kind") != "once" or times is None or times <= 0:
return True
completed = repeat.get("completed", 0)
completed = repeat.get("completed") or 0
label = job.get("name", job.get("id", "?"))
if completed >= times:
if job.get("last_run_at") is not None:
@@ -3098,7 +3098,7 @@ def _oneshot_dispatch_limit_reached(job: Dict[str, Any], scan: _DueScan) -> bool
process is still running it (a run outliving the run_claim TTL is slow, not stale)."""
repeat = job.get("repeat") or {}
times = repeat.get("times")
completed = repeat.get("completed", 0)
completed = repeat.get("completed") or 0
if times is None or times <= 0 or completed < times:
return False
name = job.get("name", job.get("id", "?"))