Hacktoberfest 2026: the issues maintainers tagged for October, open and beginner-friendly. Browse Hacktoberfest issues

fix(worker): Redis 读超时未捕获炸穿 run() 主循环——worker 进程退出、launchd 拉起后 XCLAIM 存量触发重投风暴

Closed
#48 3 comments 0 reactions 0 assignees View on GitHub

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

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 触发「交接重入队」风暴。

为什么现有防线挡不住(逐条亲核):

  1. plaita/server/task_queue.py:179 的 read() 只吞 RedisConnectionError:

    try:
        return self._read_once(block_ms)
    except RedisConnectionError:            # ← 只有这一支
        time.sleep(RECONNECT_BACKOFF_SECONDS)
        return None
    
  2. :206 的 RedisTimeoutError 只包住 xreadgroup 那一行(BLOCK 到期属常态空轮询),不覆盖它之前的 _read_once 前置调用。

  3. _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()。

  4. 关键类型事实(本机实查 redis-py 8.1.0):

    issubclass(redis.exceptions.TimeoutError,    ConnectionError) -> False
    issubclass(redis.exceptions.SocketTimeout,   ConnectionError) -> False
    

    TimeoutError 与 ConnectionError 在 redis-py 8.x 是 RedisError 下互不继承的两个兄弟分支——所以即使 read() 那层写成 except RedisConnectionError 也接不住 TimeoutError,:179 的兜底对超时类天然失效。

  5. 退出路径无兜底: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 噪声也会随之减少。

建议

  1. task_queue.read() 扩宽吞并:把 except RedisConnectionError 改为同时覆盖超时家族——
    except (RedisConnectionError, RedisTimeoutError, socket.timeout)(或更稳的 except RedisError),退避后 return None,与既有瞬断语义一致。
  2. ensure_group() 分层:BUSYGROUP 之外,对连接/超时类(RedisError)也走「记 warning + 返回(由上层轮询重试)」而非 raise;真正的 schema/权限错误仍应上抛。
  3. run() 加读循环级兜底(关键):_consume_loop 的最外层包一层 except RedisError → 记 error + time.sleep(backoff) + continue(绝不退出进程);main() 只在 worker 构造/注册失败时才 sys.exit(1)。
  4. _reclaim_one / sweep_acked_residue 同类隐患::515 xclaim、:285 xrange 的既有 except Exception 分支行为需复核(目前 sweep 已吞、reclaim 的 xpending_range 已吞,但 xclaim 与 _task_from_fields 段待确认)——建议同轮审计。

验收条件:

  1. 注入性验证(可新增 plaita-console/scripts/e2e-chaos-redis.sh 场景或单测)——stub 使 ensure_group/xreadgroup/xclaim 抛 redis.exceptions.TimeoutError:worker 进程不退出、打印一条 warning 后继续轮询;Redis 恢复后正常消费。
  2. 同法让 redis.exceptions.ConnectionError / socket.timeout 各注入一次,行为一致(不退出、可恢复)。
  3. 回归:plaita-console/scripts/e2e-chaos-redis.sh 原场景仍绿。
  4. 停机语义不回归:显式 SIGTERM / 远程 stop 命令仍能在 ≤1s 边界内优雅退出(run() 的 finally/stop() 顺序不变)。

边界

  • 不改 at-least-once 投递语义(读失败仍不 ack,消息留 pending)。
  • 不与 #47(reclaim 查租约)合并——不同因,各自独立修复。
  • 不改 xreadgroup BLOCK 到期返回 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

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Similar issues

More Python issues

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.