fix(cron): non-dict schedule crashes 6 direct-call sites the due-scan repair doesn't reach - #61758
Conversation
Related to the malformed-cron-job family: the merged class fix #61723 hardened the due-scan ( |
teknium1
left a comment
There was a problem hiding this comment.
Thanks for tracing the direct-call paths; the premise is verified on current remote main (cron/jobs.py:1337-1339, 1435, 1513, 1570, and 1645).
Problems
_job_schedule_dict()says its in-place repair is persisted by each caller (cron/jobs.py:614-633), butclaim_dispatch()returns atcron/jobs.py:1545-1546andadvance_next_run()returns atcron/jobs.py:1602-1605withoutsave_jobs(). Those calls avoid the crash but leavejobs.jsonmalformed.
Suggested changes
- Track whether
_job_schedule_dict()repaired the record and persist before those early returns. Add persistence assertions to the new direct-call tests attests/cron/test_jobs.py:1827-1833.
Automated hermes-sweeper review.
| @@ -1510,7 +1542,7 @@ def claim_dispatch(job_id: str) -> bool: | |||
| for i, job in enumerate(jobs): | |||
| if job["id"] != job_id: | |||
| continue | |||
| if job.get("schedule", {}).get("kind") != "once": | |||
| if _job_schedule_dict(job).get("kind") != "once": | |||
There was a problem hiding this comment.
_job_schedule_dict() may replace a malformed stored schedule here, but this early return skips save_jobs(jobs). The analogous early return in advance_next_run() has the same issue, so the repair does not persist despite the helper's contract. Track whether normalization occurred, save before these returns, and add persisted-state assertions.
GottZ
left a comment
There was a problem hiding this comment.
This was generated by AI during triage.
Summary
Seven PRs address or materially reference the malformed-cron-record failure family. #61723 merged the canonical due-scan class fix for missing IDs, non-dict schedules, and malformed timestamps; #61758 covers still-unprotected direct-call paths, while #40740 retains stricter interval-value validation not fully present on main and #52611 exposes a test-store isolation prerequisite relevant to #61758's added test.
Related pull requests
- #40740 [closed]
related— (+126/-5) — partially superseded: It hardens compute_next_run() against non-dict or incomplete schedules and, unlike #61723, rejects non-numeric interval minutes including strings and bools. Despite the implemented_on_main close review, #61723 only rejects None minutes, so #40740's complete validation behavior was not merged and remains relevant as a focused follow-up rather than as a duplicate of #61758. - #50377 [closed]
related— (+109/-1) — superseded by #61723: It isolates an unparseable next_run_at, repairs recurring schedules, and prevents one poisoned record from aborting healthy siblings. #61723 implements this cause more broadly through timestamp normalization plus per-job containment, which supports the contributor's implemented_on_main close verdict. - #52611 [closed]
related— (+13/-2) — independent test-safety fix: It patches cron.jobs' import-time CRON_DIR, JOBS_FILE, and OUTPUT_DIR constants so claim_job_for_fire tests cannot write to the real cron store. Although not a scheduler runtime fix, it remains relevant because #61758 adds another test using that same temp_home fixture without including this isolation change. - #61525 [closed]
related— (+85/-17) — merged through #61723: It normalizes non-dict schedules in the due scan and hardens schedule readers, directly fixing the scan-wide freeze caused by schedule.get() on None or another non-dict value. Its contributor-authored cron changes were cherry-picked into #61723 with attribution preserved. - #61581 [closed]
related— (+206/-14) — cron portion merged through #61723: Its timestamp normalization and compute_next_run() recovery prevent malformed next_run_at or last_run_at values from aborting the due scan. The unrelated process-registry redaction changes were intentionally excluded and require a separate focused PR, as documented by the contributor review. - #61723 [merged]
related— (+501/-194) — merged canonical due-scan class fix: It consolidates missing-ID repair, non-dict schedule normalization, malformed timestamp recovery, and a structural per-job exception boundary so malformed records cannot freeze healthy siblings. It is the reference implementation for the scan path, but it does not cover #61758's direct-call paths and does not fully preserve #40740's numeric interval validation. - #61758
related— (+144/-11) — merge after test-isolation check: It extends non-dict schedule repair to resume_job(), mark_job_run(), claim_dispatch(), advance_next_run(), claim_job_for_fire(), and update_job(), which #61723's due-scan repair cannot reach. The visible keep_open review identified missing persistence in claim_dispatch() and advance_next_run(); the current diff explicitly saves repaired records on both early-return paths and adds persistence assertions, addressing that review, but its claim_job_for_fire test should use the isolated fixture behavior from #52611.
Duplicates
#50377 and the cron portion of #61581 substantially overlap the malformed-timestamp repair incorporated into #61723; #61525 is likewise incorporated into #61723 for non-dict schedules. #40740 overlaps compute_next_run() hardening only partially, while #61758 and #52611 are complementary rather than duplicates.
Suggested consolidation
Merge #61758 after ensuring its claim_job_for_fire fixture includes #52611's import-time path isolation — it is the focused complement to merged #61723 and its current diff addresses the visible keep_open review's persistence blockers. Treat #50377, #61525, and the cron portion of #61581 as superseded by #61723; keep #52611 as an independent test-safety change, and track #40740's stricter non-numeric interval validation separately because that behavior was not fully implemented by #61723.
Cross-PR triage: Reviewed 7 pull requests and 0 issues in this complex. Each diff was read against this issue; Assessment working set: 89 kB of PR diffs, 19 kB of issue/PR text, 8 kB of discussion (10 comments), 3 verify verdicts. verdicts reflect diff content, not PR titles. Part of an automated triage batch.
… repair doesn't reach A stored or caller-supplied non-dict schedule (null, a stray int, etc.) is repaired by _get_due_jobs_locked() during the periodic due-scan tick (NousResearch#61525), but resume_job(), mark_job_run(), claim_dispatch(), advance_next_run()/advance_next_runs(), claim_job_for_fire(), and update_job()'s inherited-schedule fallback can all run on a record the scan hasn't repaired yet — e.g. right after a direct jobs.json edit, or on a paused job the due-scan skips. Each of those raw job["schedule"].get() call sites crashed with AttributeError instead of gracefully treating the record as an empty schedule. Introduces _job_schedule_dict(job), which repairs job["schedule"] in place (mirroring the due-scan tick's own repair), and threads it through all six sites. advance_next_runs() and claim_dispatch() additionally persist the in-place repair via save_jobs() even on paths that return before their normal save would otherwise run it, so the fix survives a restart instead of re-raising on the next direct call. update_job()'s guard is narrowed to (dict, str) so a legitimate raw string schedule update (e.g. "every 10m") is still parsed correctly.
26f8fed to
2f42be4
Compare
|
Rebased onto current The rebase had two real conflicts worth calling out:
All 522 tests in |
Summary
The recent malformed-job hardening series (#61382 id-less job, #61525 non-dict schedule, #61581 bad next_run_at, and the final per-job containment guard) fixed
_get_due_jobs_locked()so a non-dict"schedule"(null, a stray string, etc. from a direct jobs.json edit or an old writer) no longer aborts the periodic due-scan tick. That fix normalizes the schedule to{}and persists the repair viasave_jobs()— but only inside the scan.Six other places in
cron/jobs.pyreadjob.get("schedule", {}).get(...)orjob["schedule"].get(...)directly, and each can run on a record the scan hasn't repaired yet (a paused job, or any of these called before the scheduler's next tick):resume_job()mark_job_run()(two call sites)claim_dispatch()advance_next_run()claim_job_for_fire()update_job()'s inherited-schedule fallback (whenupdatesdoesn't touch"schedule"but the stored value is malformed)dict.get(key, default)only returnsdefaultwhen the key is absent — a key present with valueNonestill returnsNone. Sojob.get("schedule", {}).get("kind")crashes withAttributeError: 'NoneType' object has no attribute 'get'whenscheduleis explicitlyNone, instead of treating it as absent.All 6 were reproduced empirically against the real
cron.jobsmodule (no mocks) before this fix:Fix
Added a shared
_job_schedule_dict(job)helper that returnsjob["schedule"]as a dict, repairing it in place (mirroring the due-scan's own normalization) when it isn't one, and used it at the 5 function-level sites.update_job()gets an equivalent inline guard that's careful to skip strings, since a raw string (e.g."every 10m") is a valid update payload that's parsed later in the same function — a blanketnot isinstance(..., dict)check would have broken that path.Testing
TestScheduleNoneSiblingCrashes) intests/cron/test_jobs.pycoveringresume_job,mark_job_run,claim_dispatch,advance_next_run, andupdate_job(including a test confirming the raw-string schedule update path still works).tests/cron/test_claim_job_for_fire.pyforclaim_job_for_fire.git stash); restoring the fix makes them pass.tests/cron/suite +tests/tools/test_cronjob_run_immediate.py+ all other test files importingcron.jobs: 989 tests pass.ruff checkclean on all changed files.Checklist
ruff checkcleanupdate_jobstill works (explicit regression test)