## 新入库(`docs/演示用/`)
- `代码库全面审查报告-2026-09-14.md`
- `代码修改方案-2026-09-14.md`
- `记忆系统排查报告-2026-09-14.md`
- `记忆系统修复文档-2026-09-14.md`
- `文档一致性审计报告-2026-09-14.md`
- `多Worker接入方案-2026-09-14.md`
## 全量校对(32 个既有文档 + `AGENTS.md`)
跨 39 个文件、**1125 insertions / 148 deletions**。
⚠️ **这批改动同样不是本次会话写的**。我抽样核对过性质:是**实质内容补充**而不是
格式/换行转换。例如 `docs/44-演示流程.md` 新增两条"2026-09-14 补注":
- `启动金融Agent平台.bat` 只在**桌面**上,仓库里只有 `启动平台.bat` 这一份
(两份由同一个 `tools/make_launcher_bat.py` 产出,改完 `start.ps1` 重跑它一起更新);
- `advisor_t`(9020) 与 `offsite_t`(9006) **不在 `tools/seed_test_rbac.py` 的演示用户里**
(那里只有 `cust_t`/`risk_t`/`admin_t`/`review_t` 四个),由 `grant_*.py` 系列创建,
**重跑种子不会重建它们** —— 换机器时这两个账号登录失败,要先查 `sys_user` 有没有这两行,
而不是查密码。
这两条都是对的地方,与我这一路踩到的现象一致(我确实用到了 `advisor_t`/`offsite_t`)。
**我没有逐字审阅全部 39 个文件**,只抽样确认了改动性质与规模。若其中有需要复核的段落,
请指明文件,我逐处核对。
13 KiB
运营侧额外 Worker 接入方案
日期:2026-09-14 性质:方案文档,未修改任何代码 结论先行:"worker 只能有一个"这个前提不成立,但你的担忧(主 worker 不能被拖垮)是真实且已经存在的——只是原因和"有几个 worker"无关,而在主循环的串行 await。
〇、决策结论(2026-09-14 晚,已定)
确认信息:运营业务独立 / 邮件量很少 / 要求异步 / 采用 IMAP IDLE 长连接模式(有邮件立即拉取,无邮件进入 IDLE 等待 120 秒)。
状态:✅ 前置条件已全部确认,可开工。
结论:方案 C —— 独立常驻进程。
确认记录:
| 确认项 | 结论 | 对方案的影响 |
|---|---|---|
| 业务性质 | 运营自己的独立业务 | → 排除 A(扩多邮箱) |
| 邮件量 | 很少 | → 独立进程开销可忽略,无需为省资源合并 |
| 执行方式 | 异步 / IDLE 长连接(120s) | → 排除 B+D(同进程轮询),节奏不匹配 |
是否写 domain_event_outbox |
否,只写运营自己的表 | → 独立进程方案成立(红线 1 不触发) |
为什么 IDLE 推翻了原推荐的 B+D:
| 主 Worker | IMAP IDLE Worker | |
|---|---|---|
| 模型 | 短轮询(run_once() 快速返回,无活 sleep(1)) |
长连接阻塞(一次 IDLE 阻塞 120 秒等推送) |
| 节奏 | 1 秒 | 120 秒 |
| 返回时机 | 每轮都返回 | 只有收到推送或超时才返回 |
把"阻塞 120 秒的 IDLE"塞进"每轮 1 秒的轮询循环",即使用 gather 也会被拖住(那个 task 不返回,gather 就不完成)。两者节奏差两个数量级,混合在一起是给自己找麻烦。
选 C 的额外理由:
- 独立进程 = 主 Worker 必然不受影响,正好满足"主 worker 要保证运行"
- 邮件量很少 → 独立进程的资源开销可忽略,没有"为了省资源而合并"的理由
- IDLE 天然是"独立服务"形态,与
risk_scan_scheduler的独立入口同款
一、先纠正三个事实
1.1 项目里已经有两种 Worker 形态,不是"只能有一个"
| 入口 | 启动方式 | 形态 |
|---|---|---|
| 主 Worker | python -m app.worker |
单进程内串行跑 WorkerRuntime + OffsiteMailWorker |
| 风控扫描 | python -m app.worker.risk_scan_scheduler |
独立常驻进程(risk_scan_scheduler.py:155-169,自带 while True + serve() + main()) |
独立进程入口是有先例的,不是新东西。
1.2 底座在设计上就支持多副本
已有完整的跨进程互斥机制:
| 机制 | 位置 | 作用 |
|---|---|---|
MySQL GET_LOCK 跨进程咨询锁 |
db.py:39-68(SCAN_LOCK_NAME) |
规则扫描跨进程互斥 |
FOR UPDATE SKIP LOCKED |
Outbox 领取 | 事件不会被两个进程重复领 |
行锁 + locked_until 租约 |
AgentRun |
run 不会被重复执行 |
行锁 + lease_until/lease_id |
OffsiteMailCursor |
邮件不会被重复收 |
结论:多开一个进程不会导致重复消费,架构是留了口的。
1.3 场外邮件 Worker 早就在主 Worker 里了
__main__.py:47-52:
offsite_worker = OffsiteMailWorker(settings)
while True:
worked = await runtime.run_once()
worked = await offsite_worker.run_once() or worked
并且注释里有一段很重要的历史:
"场外收件 Worker 必须与底座 Worker 同进程同入口:2026-09-11 01:45 的一次批量文件覆盖把这处接线删掉了,导致邮件 Worker 完全不再运行、邮箱无人收取。"
所以"拉邮箱"这件事本来就属于主 Worker 的职责范围。 组员要加的如果是同类需求,第一选择应该是接进来,而不是另起炉灶。
二、真正的隐患:主循环是串行 await(已经存在)
worked = await runtime.run_once()
worked = await offsite_worker.run_once() or worked # ← 串行
offsite_worker.run_once() 内部要做 IMAP 连接 → 拉邮件 → 存附件 → OCR/大模型识别 → 写业务表,一次批次耗时可能几十秒。
在此期间主循环完全停摆:agent_run 排队、domain_event_outbox 事件堆积、记忆抽取停滞。
这才是"主 worker 要保证运行"的真正威胁——它跟新加几个 worker 无关,现在就存在。
三、先问清一个问题(决定走哪条路)
组员要拉的邮箱,是"另一个邮箱的同类场外基金邮件",还是"运营自己的、和场外基金完全无关的业务邮件"?
这两者的答案完全不同。
四、方案 A:同类邮件、另一个邮箱 —— 扩成多邮箱(推荐)
事实支撑(表结构天生就支持,是代码没用上)
OffsiteMailCursor 有:
UniqueConstraint("mailbox", "folder", name="uk_offsite_mail_cursor")
但 _claim_cursor()(offsite_mail_worker.py:208)写死了单邮箱:
.where(OffsiteMailCursor.mailbox == self.settings.offsite_mailbox, ...)
唯一约束是按 (mailbox, folder) 建的,说明设计时就是打算支持多邮箱的,只是配置层收成了一个。
做法
- 配置从
offsite_mailbox: str扩成多邮箱列表(或加一张邮箱配置表) OffsiteMailWorker持有多份配置,或主循环按邮箱逐个构造/调用_claim_cursor按当前 mailbox 取游标(现有逻辑不用改,只要 mailbox 是可变的)
评价
| 项 | 结论 |
|---|---|
| 新增进程 | ❌ 不需要 |
| 复用现成能力 | ✅ 租约、退避、死信、附件存储、识别全复用 |
| 部署改动 | ✅ 无 |
| 残留风险 | ⚠️ 仍在同一进程 → 必须配合方案 D |
五、方案 B:独立业务 —— 挂到主入口,但改成并发(推荐)
做法
在 __main__.py 里新增一个 worker 实例,但不要用 await 串行调,改为并发:
- 轻量的:两个 worker 各自
asyncio.create_task,主循环按worker_poll_seconds轮询 - 或:主循环每轮用
asyncio.gather(*(w.run_once() for w in workers)),并给每个 worker 加超时
评价
| 项 | 结论 |
|---|---|
| 新增进程 | ❌ 不需要,部署形态不变 |
| 隔离性 | ⚠️ 同进程,一个 worker 崩溃会连累(但可用 try/except 兜住) |
| 改动量 | 小(约 20 行) |
| 附带收益 | ✅ 顺带修掉 §2 的串行阻塞隐患 |
必须遵守
每个 worker 的 run_once() 外面都要 try/except 吞异常并记日志,绝不能让一个 worker 的异常跳出主循环。项目已有这个模式(__main__.py:53-62),照抄即可。
六、方案 C:独立业务 —— 独立进程
事实支撑
risk_scan_scheduler.py 已经是这个形态,直接照抄 serve() / main() 结构。
做法
新建入口(如 python -m app.worker.ops_mail),独立部署、独立守护。
评价
| 项 | 结论 |
|---|---|
| 隔离性 | ✅ 最好:互不影响,一个挂了另一个照跑 |
| 运维成本 | ⚠️ 多一个进程要守护/重启/监控 |
| 主 Worker 安全 | ✅ 完全不受影响 |
硬前提(最重要)
新进程绝不能消费底座的队列(domain_event_outbox / memory_sync_outbox)。
项目真实踩过这个坑,__main__.py:31-44 有完整记录:
本入口曾有一个
MemorySyncOutboxWorker与 runtime 内那套同时读同一个队列,而两套的 handler 并不相同……同一事件被哪套领到结果不定,等于同一事实在图里有两种说法。
判断标准:新 worker 只拉自己的邮箱、写自己的业务表(或投递到自己的事件类型),与底座队列无交集 → 安全。
一旦它要消费 domain_event_outbox,就必须回到方案 B。
七、方案 D:无论选 A/B/C 都建议做 —— 主循环解耦
把"串行 await 多个 worker"改成"并发 / 独立 task",让慢的 worker 不拖住快的。
这是独立收益项:就算最后决定不加任何新 worker,当前 offsite_worker.run_once() 串行阻塞主循环的问题也值得单独修。
八、决策矩阵
| 同类邮件、另一个邮箱 | 独立业务(运营自有) | |
|---|---|---|
| 首选 | A + D | B + D(同进程)或 C(独立进程) |
| 何时选 C | — | 邮件量大 / 处理耗时长 / 要求故障隔离 |
| 新增进程 | 否 | 仅 C |
| 隔离性 | 低 | B 中 / C 高 |
| 改动量 | 小 | B 小 / C 中 |
| 部署改动 | 无 | C 需新增守护 |
九、四条红线(均来自项目真实事故)
- 不要两套 worker 消费同一个队列 ——
MemorySyncOutboxWorker事故,导致同一事实在图里有两种说法。 - 新 worker 必须自带租约或幂等 —— 不能假设"只会有一个实例"。参考
OffsiteMailCursor的lease_until+lease_id+ 心跳续租。 - 异常必须被吞掉并记日志 —— 不能让单个 worker 的失败杀掉主循环(
__main__.py:53-62已是范例)。 - 独立进程必须独立可观测 —— 至少要有独立日志前缀 + 健康检查,否则"它挂了"这件事无人知晓(历史上场外 Worker 被误删接线后长期无人发现)。
十、补充:IDLE 模式的实现要点(给组员)
IMAP IDLE(RFC 2177)的标准流程:
连接 → 登录 → SELECT 收件箱
└─ 发 IDLE 命令 → 阻塞等待(最长 120s)
├─ 收到服务器推送(如 `* N EXISTS`)→ 发 DONE → FETCH 拉新邮件 → 处理
└─ 120s 超时无推送 → 发 DONE → 重新发 IDLE(保活)
四个必须注意的坑
1. 必须用 asyncio.to_thread 包住 IDLE(最严重)
IDLE 是同步阻塞的(阻塞 120 秒等推送)。如果在协程里直接调用,会冻结整个事件循环 120 秒——同进程的 Agent 执行、outbox 消费全部停摆。
项目里已有正确范例,照抄:offsite_mail_worker.py:290
messages = await asyncio.to_thread(...)
2. 120 秒要主动退出并重新 IDLE
不要指望一次 IDLE 长驻。服务器和中间网络设备(NAT/防火墙)都会掐断长时间静默的连接。每 120 秒退出重发是正确做法,也符合 RFC 2177 的建议(客户端应定期退出并重发 IDLE,规范建议上限 29 分钟)。
3. 断线重连 + 退避
IDLE 长连接必然会遇到:网络抖动、服务器重启、超时踢下线。需要:
- 捕获连接异常 → 重新登录 → 重新 SELECT → 重新 IDLE
- 重连间隔加指数退避(参考
runtime.py:901-904的既有实现),避免服务器不可用时疯狂重连
4. 优雅退出与资源释放
独立进程必须处理 SIGTERM,否则重启部署时 IMAP 连接与数据库引擎泄漏。照抄 risk_scan_scheduler.py:168-169:
finally:
await engine.dispose()
IDLE 模式下仍需遵守的红线
- 单实例假设是危险的:IDLE 长连接看似"天然单实例",但一旦将来有人多开一个进程做高可用,两个实例同时 IDLE 同一邮箱会重复收件。建议现在就加一道互斥(直接复用
OffsiteMailCursor的lease_until+lease_id思路,或用一个 MySQLGET_LOCK),成本很低。 - 不碰
domain_event_outbox:这条不变。如果运营 Worker 处理完邮件需要触发底座流程,回来找我,改走同进程方案。
十一、确认记录与后续约束
11.1 四项前置全部已确认
| # | 问题 | 结论 |
|---|---|---|
| 1 | 同类还是独立业务 | 运营自己的独立业务 |
| 2 | 邮件量与耗时 | 很少 |
| 3 | 能否接受同进程 | 要求异步;且 IDLE 长连接后同进程轮询方案已不适用 |
| 4 | 是否写 domain_event_outbox |
否,只写运营自己的表 ✅ |
11.2 后续若需求变更,必须回来重新评估
以下任一情况出现,独立进程方案不再成立,需要重新设计:
- ⚠️ 改为需要触发底座流程(生成 Agent 任务 / 触发风控预警 / 触发记忆抽取)→ 必须写
domain_event_outbox→ 回到同进程方案 - ⚠️ 改为需要消费
domain_event_outbox/memory_sync_outbox的事件 → 两套消费者抢同一队列,重演MemorySyncOutboxWorker事故 - ⚠️ 邮件量增长到需要多实例 → 必须先补互斥(§10 红线),否则重复收件
11.3 验收清单(上线前逐条确认)
- IDLE 调用已包
asyncio.to_thread(否则冻结事件循环) - 120 秒后主动
DONE并重新 IDLE(保活) - 断线重连带指数退避(不疯狂重连)
finally: await engine.dispose()(优雅退出)- 单实例互斥已加(防将来多开重复收件)
- 确认未引用
domain_event_outbox/memory_sync_outbox - 独立进程有独立日志前缀 + 健康检查(否则挂了无人知晓——场外 Worker 被误删接线的教训)
- 主 Worker 与该进程同时运行时,主 Worker 的 outbox 消费速率无明显下降