Merge PR #3038: fix(cron): guard _load_store against reentrant reload during job execution

fix(cron): guard _load_store against reentrant reload during job execution
This commit is contained in:
Xubin Ren 2026-04-11 16:28:47 +08:00 committed by GitHub
commit 1263869c0a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

View File

@ -479,3 +479,49 @@ def test_update_job_sentinel_channel_and_to(tmp_path) -> None:
assert isinstance(result, CronJob)
assert result.payload.channel is None
assert result.payload.to is None
@pytest.mark.asyncio
async def test_list_jobs_during_on_job_does_not_cause_stale_reload(tmp_path) -> None:
"""Regression: if the bot calls list_jobs (which reloads from disk) during
on_job execution, the in-memory next_run_at_ms update must not be lost.
Previously this caused an infinite re-trigger loop."""
store_path = tmp_path / "cron" / "jobs.json"
execution_count = 0
async def on_job_that_lists(job):
nonlocal execution_count
execution_count += 1
# Simulate the bot calling cron(action=list) mid-execution
service.list_jobs()
service = CronService(store_path, on_job=on_job_that_lists, max_sleep_ms=100)
await service.start()
# Add two jobs scheduled in the past so they're immediately due
now_ms = int(time.time() * 1000)
for name in ("job-a", "job-b"):
service.add_job(
name=name,
schedule=CronSchedule(kind="every", every_ms=3_600_000),
message="test",
)
# Force next_run to the past so _on_timer picks them up
for job in service._store.jobs:
job.state.next_run_at_ms = now_ms - 1000
service._save_store()
service._arm_timer()
# Let the timer fire once
await asyncio.sleep(0.3)
service.stop()
# Each job should have run exactly once, not looped
assert execution_count == 2
# Verify next_run_at_ms was persisted correctly (in the future)
raw = json.loads(store_path.read_text())
for j in raw["jobs"]:
next_run = j["state"]["nextRunAtMs"]
assert next_run is not None
assert next_run > now_ms, f"Job '{j['name']}' next_run should be in the future"