fix(worker): Redis 读超时未捕获炸穿 run() 主循环——worker 进程退出、launchd 拉起后 XCLAIM 存量触发重投风暴
Nobody has claimed this yet.
Assessment
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Newbie friendliness
- 35/100
- Issue type
- Bug
- Clarity
- Clearly specified
- Activity status
- Active
- Tech stack
- python, redis
- Domain
- backend, distributed-systems
Research direction
Start with plaita/server/task_queue.py, especially read(), _read_once(), ensure_group(), and the reclaim paths named in the issue; then inspect the worker’s _consume_loop and run() handling. Run plaita-console/scripts/e2e-chaos-redis.sh and review or add tests that inject Redis timeout and connection errors. Done means the worker stays alive through transient failures, recovers to consume messages, and still shuts down gracefully.
Written by the indexing model from the issue text.
Description
优先级 P1 · 依赖:无
基线 HEAD = 44febf5
Summary
worker 的 Redis 连接一旦出现读超时(redis.exceptions.TimeoutError / SocketTimeout),异常会未捕获地炸穿 run() 主循环,整个 worker 进程退出,靠 launchd/systemd 拉起——在跑 run 的监视线程随之中断、新实例启动时又 XCLAIM 存量 PEL 触发「交接重入队」风暴。
为什么现有防线挡不住(逐条亲核):
-
plaita/server/task_queue.py:179的read()只吞RedisConnectionError:try: return self._read_once(block_ms) except RedisConnectionError: # ← 只有这一支 time.sleep(RECONNECT_BACKOFF_SECONDS) return None -
:206的RedisTimeoutError只包住xreadgroup那一行(BLOCK 到期属常态空轮询),不覆盖它之前的_read_once前置调用。 -
_read_once的第一行就是self.ensure_group()(:187-188→:154-165),而ensure_group内部xgroup_create的except Exception只放过BUSYGROUP,其余一律raise:def ensure_group(self) -> None: try: self.redis.xgroup_create(self.stream_key, self.group_name, id="0", mkstream=True) except Exception as exc: if "BUSYGROUP" not in str(exc): raise # ← TimeoutError 从这里逃出于是「读超时」这条路径绕开了
:179的 ConnectionError 兜底,从_read_once直穿到_consume_loop→run()。 -
关键类型事实(本机实查 redis-py 8.1.0):
issubclass(redis.exceptions.TimeoutError, ConnectionError) -> False issubclass(redis.exceptions.SocketTimeout, ConnectionError) -> FalseTimeoutError与ConnectionError在 redis-py 8.x 是RedisError下互不继承的两个兄弟分支——所以即使read()那层写成except RedisConnectionError也接不住TimeoutError,:179的兜底对超时类天然失效。 -
退出路径无兜底:
run()(:2310-2331)的try/finally只在 finally 里self.stop()(停机收殓),没有 except;异常继续上传到main()的except Exception(:2844-2847),打印「流程工作器启动失败」后sys.exit(1)。
实锤证据(本机 worker 日志,~/.plaita-console/worker-mac.log)
两次真实崩溃(无信号、非正常停机):
| 时间 | 首个未捕获异常链 | 结果 |
|---|---|---|
| 2026-10-07 01:22:04 | run:2086 → task_queue.read:143 → _read_once:153 → ensure_group:121 → xgroup_create → redis.exceptions.TimeoutError: Timeout reading from socket(log L1678-1832) |
流程工作器启动失败(L1836)→ 重启 |
| 2026-10-07 02:48:45 | run:2096 → _consume_loop:2124 → queue.read:143 → _read_once:153 → ensure_group:121 → xgroup_create → 同款 TimeoutError(L2057-2213) |
流程工作器启动失败(L2215)→ 重启 |
两次都没有「收到信号」行(全日志仅 3 处 SIGTERM:10-06 22:53、10-07 14:50、10-07 21:38),佐证是异常退出而非计划内停机。
反例(用于排除误判):D-0017 线索指向的 21:38:59 那条 ValueError: I/O operation on closed file(control.py:153 _listen_loop)不是崩溃主因——它前面一行是 21:38:58 收到信号 15,请求优雅停机(L15570-15573),属正常关机过程中控制监听线程关闭 pub/sub 后的次生噪声(同一模式在 10-06 22:53、10-07 14:50 两次显式停机时各出现一次)。control.py:154 本身已吞 (RedisTimeoutError, RedisConnectionError, OSError),且 _listen_loop 由线程承载、外层 except Exception(:161-162)收口,不会终止进程。
对照:registry.py 的心跳/注销路径已正确吞异常(heartbeat 的 except Exception :275-277 返回 False;unregister 同样收口),所以日志里的「心跳失败/服务注销失败」只是停机次生噪声,不是主因——这也解释了为什么崩溃现场看得见这些行:它们是 stop() 收殓阶段 Redis 已不可达的产物。
影响
- 单点重启 = 在跑 run 监控中断:worker 进程退出后,本机的执行监视线程全断;已跑 run 只能靠 keeper reaper 兜底(执行级恢复),期间长时间无进展。
- 队列 churn:新实例启动即
_reclaim_one+_read_once,对未消费的 pending 消息触发 XCLAIM 重投(与 #47 的 reclaim-churn 叠加,实测 21:40 新实例启动后 21:40:26-47 出现一波交接重入队,XLEN 15→25)。 - 任何 Redis 抖动都可能复现:读超时/连接重置/主机重压下的连接回收都会打到这条未捕获路径;重载
plaita:flow:queue:v2的部署(含 VM 侧 keeper/worker)同等暴露。 - 与 #47 的区别:#47 是「claim 不查活跃租约 → 长 run 消息被反复重投」;本单是「读超时未捕获 → 进程退出」。两者不同因、可叠加:本单修好后,进程不再因 Redis 抖动自杀,#47 的 churn 噪声也会随之减少。
建议
task_queue.read()扩宽吞并:把except RedisConnectionError改为同时覆盖超时家族——
except (RedisConnectionError, RedisTimeoutError, socket.timeout)(或更稳的except RedisError),退避后return None,与既有瞬断语义一致。ensure_group()分层:BUSYGROUP之外,对连接/超时类(RedisError)也走「记 warning + 返回(由上层轮询重试)」而非raise;真正的 schema/权限错误仍应上抛。run()加读循环级兜底(关键):_consume_loop的最外层包一层except RedisError→ 记 error +time.sleep(backoff)+continue(绝不退出进程);main()只在 worker 构造/注册失败时才sys.exit(1)。_reclaim_one/sweep_acked_residue同类隐患::515 xclaim、:285 xrange的既有except Exception分支行为需复核(目前sweep已吞、reclaim的xpending_range已吞,但xclaim与_task_from_fields段待确认)——建议同轮审计。
验收条件:
- 注入性验证(可新增
plaita-console/scripts/e2e-chaos-redis.sh场景或单测)——stub 使ensure_group/xreadgroup/xclaim抛redis.exceptions.TimeoutError:worker 进程不退出、打印一条 warning 后继续轮询;Redis 恢复后正常消费。 - 同法让
redis.exceptions.ConnectionError/socket.timeout各注入一次,行为一致(不退出、可恢复)。 - 回归:
plaita-console/scripts/e2e-chaos-redis.sh原场景仍绿。 - 停机语义不回归:显式 SIGTERM / 远程 stop 命令仍能在 ≤1s 边界内优雅退出(
run()的 finally/stop()顺序不变)。
边界
- 不改 at-least-once 投递语义(读失败仍不 ack,消息留 pending)。
- 不与 #47(reclaim 查租约)合并——不同因,各自独立修复。
- 不改
xreadgroupBLOCK 到期返回 None 的既有约定(:206-210)。
- Dominant language
- Python
- Stars
- 0
- Forks
- 1
- Avg merge
- 3h
- Merged PRs (30d)
- 6
Getting set up
This project ships no dev container, Dockerfile or contributing guide, so setting up is up to you: start from its README, and see our first-contribution guide for the general steps.
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Similar issues
-
documentation good first issue
Difficulty 2/5 1-3 hours Newbie friendliness 82/100
selimfirat/pysad#379 ·
Maintainers usually reply within 1 day
-
bug
Difficulty 2/5 1-3 hours Newbie friendliness 75/100
open-webui/open-webui#32805 ·
Maintainers usually reply within 1 day
-
fix: [bug description] HTML CVE metric table shows only the last CVE for each productPossibly taken @Chris-wh233 claimed this today. Openbug
Difficulty 2/5 1-3 hours Newbie friendliness 66/100
ossf/cve-bin-tool#5976 ·
Maintainers usually reply within 1 day
-
needs-triage
Difficulty 2/5 1-3 hours Newbie friendliness 72/100
gke-labs/kube-agents#3615 ·
Maintainers usually reply within 1 day
-
deferred
Difficulty 2/5 1-3 hours Newbie friendliness 70/100
Maintainers usually reply within 1 day