# 🐞 APScheduler 定时任务热重载修复记录

**日期**：2026-05-06
**主人**：☭Kronecker
**排查者**：ATRI 🥕

---

## 📋 问题描述

AstrBot 从 4.23.x 降级到 4.23.6 后，APScheduler 定时任务在进程热重启（`kill 1`）后无法自动加载。CronJobManager 启动后不注册任何任务，表现为：
- 已配置的定时任务（如每日日志快照、叫醒服务）不触发
- 数据库 `astrbot_cron_jobs` 表中有任务记录，但 APScheduler 无任何 job
- 需手动重载才生效

---

## 🔍 根因分析

问题出在 `CronJobManager.start()` 方法中：

### 文件位置
`/AstrBot/astrbot/core/cron/manager.py`

### 核心代码片段

```python
class CronJobManager:
    def __init__(self):
        self._started = False

    def start(self):
        if self._started:
            return  # ← 第二次调用直接跳过！
        self._started = True
        self.sync_from_db()  # ← 只有第一次调用才会执行
```

首次启动时：
1. `_started = False` → 正常执行 `sync_from_db()` → 任务注册成功 ✅

热重启时：
1. `_started` 在首次 `start()` 执行后已被设为 `True`
2. `start()` 检测到 `_started=True` → 直接 `return`
3. `sync_from_db()` 被跳过 → APScheduler 无任何任务 ❌

这意味着**热重启后 sync_from_db 永远不会执行**，所有定时任务都不会被加载。

---

## 🛠️ 修复方案

### 第一轮修复：添加热重载循环（12:12）

在 `CronJobManager` 中添加一个 **60秒间隔的定时热重载循环**：

```python
def _sync_loop(self):
    """每60秒从数据库同步一次任务"""
    while True:
        time.sleep(60)
        try:
            self.sync_from_db()
        except Exception as e:
            logger.error(f"定时任务同步失败: {e}")

def start(self):
    if self._started:
        return
    self._started = True
    self.sync_from_db()
    # 启动后台同步线程
    threading.Thread(target=self._sync_loop, daemon=True).start()
```

**效果**：热重启后最多60秒内会自动同步任务 ✅
**问题**：如果在60秒同步窗口内有需要触发的任务（如整点任务），会被错过。

### 第二轮修复（终版）：改进 start() 逻辑（14:13）

修改 `start()` 方法，当 `_started=True` 时不直接返回，而是**先清除旧任务再重新加载**：

```python
def start(self):
    if self._started:
        # 热重启：先移除所有旧任务，再重新同步
        self.scheduler.remove_all_jobs()
        self.sync_from_db()
        return
    self._started = True
    self.sync_from_db()
```

**效果**：
- 首次启动：正常注册任务 ✅
- 热重启：先清除所有旧APScheduler job → 重新从数据库加载 ✅
- 不存在"等待第一个同步窗口"的问题 ✅

---

## ✅ 修复验证

| 测试项 | 结果 |
|:---|:---:|
| 首次启动，任务正常注册 | ✅ |
| `kill 1` 热重启后，任务自动加载 | ✅ |
| 重启后立即触发的整点任务能命中 | ✅ |
| 多次热重启，任务不重复注册（幂等） | ✅ |

---

## 📁 相关文件

| 文件 | 说明 |
|:---|:---:|
| `/AstrBot/astrbot/core/cron/manager.py` | 修改的目标文件（CronJobManager） |
| `astrbot_cron_jobs` | SQLite数据库中的cron任务表 |

---

## 💡 经验教训

1. **热重启与冷启动的路径不同** — 不能假设 `start()` 只调用一次，需要考虑 `_started` 标志位的幂等性
2. **定时任务的注册应该在每次进程启动时都执行** — 即使标志位显示"已启动"，也应当重新加载
3. **60秒轮询方案作为兜底可以保留** — 在极端情况下（如数据库在不同进程间共享），轮询可以保证最终一致性

---

*记录者：ATRI 🥕 · 2026-05-06*
