fix(cron): guard public APIs against unavailable store

This commit is contained in:
yorkhellen
2026-06-28 19:46:44 +08:00
committed by Xubin Ren
parent c90e433057
commit e5dbb15c34
2 changed files with 173 additions and 10 deletions
+28 -9
View File
@@ -357,6 +357,25 @@ class CronService:
return self._store
def _require_store(self) -> CronStore:
"""Return a usable store or raise a clear error.
``_load_store`` deliberately returns ``None`` when the first load sees
a corrupt on-disk store and no previous in-memory snapshot exists. The
public API requires a concrete store object before touching
``store.jobs``; raising here keeps callers from seeing an accidental
``AttributeError`` and, more importantly, prevents follow-up saves from
treating a corrupt store as an empty one.
"""
store = self._load_store()
if store is None:
raise RuntimeError(
f"cron store at {self.store_path} could not be loaded and was preserved "
"as a .corrupt-<ts> backup; refusing to operate to avoid overwriting "
"scheduled jobs. Inspect the corrupt backup and restore jobs.json manually."
)
return store
def _save_store(self) -> None:
"""Save jobs to disk."""
if not self._store:
@@ -622,7 +641,7 @@ class CronService:
def list_jobs(self, include_disabled: bool = False) -> list[CronJob]:
"""List all jobs."""
store = self._load_store()
store = self._require_store()
jobs = store.jobs if include_disabled else [j for j in store.jobs if j.enabled]
return sorted(jobs, key=lambda j: j.state.next_run_at_ms or float('inf'))
@@ -684,7 +703,7 @@ class CronService:
_normalize_agent_turn_job(job)
self._enforce_agent_binding(job)
if self._running:
store = self._load_store()
store = self._require_store()
store.jobs.append(job)
self._save_store()
self._arm_timer()
@@ -696,7 +715,7 @@ class CronService:
def register_system_job(self, job: CronJob) -> CronJob:
"""Register an internal system job (idempotent on restart)."""
store = self._load_store()
store = self._require_store()
now = _now_ms()
job.state = CronJobState(next_run_at_ms=_compute_next_run(job.schedule, now))
job.created_at_ms = now
@@ -710,7 +729,7 @@ class CronService:
def remove_job(self, job_id: str) -> Literal["removed", "protected", "not_found"]:
"""Remove a job by ID, unless it is a protected system job."""
store = self._load_store()
store = self._require_store()
job = next((j for j in store.jobs if j.id == job_id), None)
if job is None:
return "not_found"
@@ -735,7 +754,7 @@ class CronService:
def enable_job(self, job_id: str, enabled: bool = True) -> CronJob | None:
"""Enable or disable a job."""
store = self._load_store()
store = self._require_store()
for job in store.jobs:
if job.id == job_id:
job.enabled = enabled
@@ -770,7 +789,7 @@ class CronService:
For ``channel`` and ``to``, pass an explicit value (including ``None``)
to update; omit (sentinel ``...``) to leave unchanged.
"""
store = self._load_store()
store = self._require_store()
job = next((j for j in store.jobs if j.id == job_id), None)
if job is None:
return "not_found"
@@ -815,7 +834,7 @@ class CronService:
was_running = self._running
self._running = True
try:
store = self._load_store()
store = self._require_store()
for job in store.jobs:
if job.id == job_id:
if self._is_unbound_agent_job(job):
@@ -835,12 +854,12 @@ class CronService:
def get_job(self, job_id: str) -> CronJob | None:
"""Get a job by ID."""
store = self._load_store()
store = self._require_store()
return next((j for j in store.jobs if j.id == job_id), None)
def status(self) -> dict:
"""Get service status."""
store = self._load_store()
store = self._require_store()
return {
"enabled": self._running,
"jobs": len(store.jobs),