fix(registry): 节点插件发现失败不可观测且不可重试——`_discovered` 在扫描前置位、`ep.load()` 异常只留一行无 traceback 的 warning,解析期报成「节点类型不存在」
Maintainers usually reply within 1 day
Nobody has claimed this yet.
Assessment
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Newbie friendliness
- 66/100
Research direction
Start in discover() and _discover_entry_points() in plaita/node/init.py: the _discovered flag is set before the scan, and the per-entry-point except Exception drops both the exception and its traceback. Reproduce with a mocked entry_points that raises, then call discover() again to confirm it returns without rescanning. Done when a failed scan allows a retry, plugin load errors are kept on the registry and logged with exc_info, and the new test file tests/unit/test_node_registry_discovery.py passes.
Written by the indexing model from the issue text.
Description
优先级 P2 · 依赖:无
基线 HEAD = 65f48f2
Summary
NodeRegistry.discover() 把「发现一次」的标记 _discovered 在扫描之前置位,且扫描本身、以及每个 entry point 的 ep.load() 失败都被静默吞掉。结果是:插件发现一旦失败,本进程内永久不再重试,且失败原因(缺哪个模块、什么异常)从日志与错误文案里完全消失。
证据 1 — _discovered 在扫描前置位,失败后不再重试
plaita/node/__init__.py:190-199(逐字):
def discover(self) -> "NodeRegistry":
"""Lazily discover node plugins via ``plaita.nodes`` entry points.
Idempotent: only scans on the first call for this registry instance.
"""
if self._discovered:
return self
self._discovered = True
self._discover_entry_points()
return self
_discovered 初值在 plaita/node/__init__.py:82:
self._discovered = False
_discover_entry_points() 一旦抛异常,_discovered 已经是 True,此后任何 discover() 调用都在 :195-196 直接 return self,不再进入扫描。
证据 2 — entry_points() 调用不在任何 try 内
plaita/node/__init__.py:168-188(逐字,注意 :174 无保护):
def _discover_entry_points(self) -> None:
# entry_points(group=...) requires Python 3.10+.
# Python 3.9 returns a dict-like SelectableGroups from entry_points().
# Test mocks may patch entry_points to return a flat list directly.
import sys
if sys.version_info >= (3, 10):
eps = entry_points(group="plaita.nodes")
else:
raw = entry_points()
if hasattr(raw, "get"):
# Python 3.9 SelectableGroups / dict API
eps = raw.get("plaita.nodes", [])
else:
# Flat iterable (e.g. test mock returns [ep, ...] directly)
eps = raw
for ep in eps:
try:
node_cls = ep.load()
self._nodes[node_cls.node_type] = node_cls
except Exception:
_logger.warning("Failed to load node plugin: %s", ep.name)
:187-188 的 except Exception: 没有绑定异常对象、没有 exc_info=True、也没有记录到任何可查询的属性上。
证据 3 — 失败插件的异常信息与 traceback 全部丢失(实跑)
在 /tmp/plaita-audit-venv/bin/python(Python 3.12,editable 装 plaita[dev,all] + plaita-nodes)下实跑:
$ cd /Users/jeff.jie/projects/infra4agent/plaita && /tmp/plaita-audit-venv/bin/python - <<'EOF'
import logging
logging.basicConfig(level=logging.DEBUG, format='%(levelname)s:%(name)s:%(message)s')
from unittest.mock import patch, MagicMock
from plaita.node import NodeRegistry
reg = NodeRegistry()
ep = MagicMock(); ep.name = 'delay'
ep.load.side_effect = ModuleNotFoundError("No module named 'kafka'")
with patch('plaita.node.entry_points', return_value=[ep]):
reg.discover()
print('hasattr _load_errors:', hasattr(reg, '_load_errors'))
try:
reg.parse_node({'type': 'delay'})
except RuntimeError as e:
print('parse_node ->', e)
EOF
WARNING:plaita.node:Failed to load node plugin: delay
hasattr _load_errors: False
parse_node -> unRecognized node type: delay. Available node types: ['assignment', 'case', 'child', 'end', 'event', 'filter', 'find', 'http', 'if', 'loop', 'map', 'mock', 'parallel', 'reduce', 'reference', 'start', 'switch', 'while']
日志里只有一个插件名 delay,异常文本 No module named 'kafka' 与 traceback 全部丢失;registry 上不存在任何承载加载失败的属性(_load_errors 为 False)。
证据 4 — 解析期错误文案把方向引向「节点名写错」
plaita/node/__init__.py:127-142(逐字):
node_cls = self._nodes.get(node_type)
if not node_cls:
import difflib
available = sorted(self._nodes.keys())
close = difflib.get_close_matches(node_type, available, n=3, cutoff=0.6)
hint = ""
if node_type == "code":
hint = (
" CodeNode was moved out of the default registry in 0.4.0; "
"call register_code_node() at startup to opt in."
)
elif close:
hint = f" Did you mean {close!r}?"
hint += f" Available node types: {available}"
raise RuntimeError(f"unRecognized node type: {node_type}.{hint}")
hint 分支只覆盖 code 与 difflib 近似名,没有任何「该类型本应由插件提供、但插件加载失败」的提示。上面证据 3 的输出即为该分支的真实产物。
证据 5 — 重试入口只有显式 init_default_registry,隐式路径无 try
plaita/node/__init__.py:241-247(逐字):
global _default_registry_explicitly_initialized
reg = _default_registry
reg._nodes.clear()
reg._discovered = False
reg._register_builtins()
if auto_discover:
reg.discover()
plaita/node/__init__.py:258-277(逐字,:276 无 try 包裹):
def get_default_registry() -> NodeRegistry:
"""Return the process-wide default ``NodeRegistry``.
Plugin discovery via ``plaita.nodes`` entry points happens lazily on the
first call, not at import time, so importing ``plaita.node`` does not pull
in optional server/plugin dependencies.
若未先调 ``init_default_registry``, 首次调用走"隐式 discover"并 ``debug``
提示——建议在启动脚本显式调 ``init_default_registry()`` 一次, 把进程级
单例状态变成显式、可复现的启动步骤。
"""
global _default_registry_explicitly_initialized
if not _default_registry_explicitly_initialized and not _default_registry._discovered:
_logger.debug(
"get_default_registry() called without explicit init_default_registry(); "
"doing implicit plugin discovery. Call init_default_registry() at startup "
"for an explicit, reproducible node set."
)
_default_registry.discover()
return _default_registry
即:entry_points() 抛错时异常从 get_default_registry() 冒出(:276),调用方若在启动期宽 catch 吞掉,本进程内除了显式再调一次 init_default_registry() 之外没有任何重试通道。
证据 6 — 发现一次即不再重试(实跑)
$ cd /Users/jeff.jie/projects/infra4agent/plaita && /tmp/plaita-audit-venv/bin/python - <<'EOF'
from unittest.mock import patch
from plaita.node import NodeRegistry
reg = NodeRegistry()
with patch('plaita.node.entry_points', side_effect=RuntimeError('boom')) as m:
try: reg.discover()
except RuntimeError as e: print('first raised:', e)
print('call_count after first:', m.call_count)
reg.discover(); print('second returned silently, call_count:', m.call_count)
print('_discovered =', reg._discovered)
EOF
first raised: boom
call_count after first: 1
second returned silently, call_count: 1
_discovered = True
第二次 discover() 静默返回,entry_points 调用次数仍为 1,_discovered 已为 True——「失败一次,本进程永久放弃发现」。
证据 7 — 解析链路默认走 get_default_registry()
plaita/core/flow.py:158-162(逐字):
from plaita.node import Node, get_default_registry
if not self.nodes:
return
reg = registry if registry is not None else get_default_registry()
证据 8 — 被发现的入口点数量(本机实跑)
$ /tmp/plaita-audit-venv/bin/python -c "from importlib.metadata import entry_points; eps=list(entry_points(group='plaita.nodes')); print(len(eps))"
27
其中 plaita 自带 5 个(pyproject.toml:94-99),plaita-nodes 注册 22 个;pyproject.toml:94-99(逐字):
[project.entry-points."plaita.nodes"]
delay = "plaita.server.nodes.delay_node:DelayNode"
redis_queue = "plaita.server.nodes.redis_queue_node:RedisQueueNode"
kafka_queue = "plaita.server.nodes.kafka_queue_node:KafkaQueueNode"
http_callback = "plaita.server.nodes.http_callback_node:HttpCallbackNode"
approval = "plaita.server.nodes.approval_node:ApprovalNode"
这 27 个入口点全部走 :184-188 同一个 except Exception(注意 kafka_queue 等条目依赖可选三方包,缺失即命中该分支)。全仓无任何调用点检查加载失败(grep -rn "_load_errors" plaita/ 零命中,且该属性不存在)。
影响
- 谁会踩到:任何按文档装了
plaita[all]或 plaita 单仓plaita(仅 core + 自带 server 节点)的部署。只要宿主环境里缺某个节点的可选依赖(例如kafka_queue需要 kafka 客户端、redis_queue需要 redis 客户端),或插件包本身 import 期报错,该节点就在 registry 里静默缺席。 - 代价:
- 不可观测:日志只有一行
Failed to load node plugin: delay,没有异常类型、异常文本、traceback,也没有任何可查询属性;运维看到的是「节点类型不存在」,而不是「延迟节点插件因缺 kafka 模块未加载」。 - 误导排查方向:
resolve_nodes()抛出的unRecognized node type: delay. Available node types: [...]把作者引向「节点名写错/版本升级改名」,而真实原因是环境缺依赖。 - 不可恢复:若失败发生在
entry_points()调用本身(:174不在 try 内),异常从get_default_registry()(:276)冒出,启动期宽 catch 的调用方吞掉后,进程内_discovered=True,不再有任何隐式重试,该进程后续所有 flow 解析都缺这 27 个插件节点;即使临时把依赖装上,也必须重启进程或显式再调init_default_registry()。
- 不可观测:日志只有一行
- 生产可达:worker / server 进程与 flow 解析链路(
core/flow.py:162)默认都走get_default_registry(),无需库嵌入者特殊操作即可命中。 - 不修的后果:一次环境缺依赖会被当成「流程写错」长期排查,且同一进程内的后续所有解析都带着错误的节点集合运行,直到重启。
建议
-
保留失败现场:在
_discover_entry_points()中把每个失败记录到实例属性(例如self._load_errors: Dict[str, BaseException],key 为ep.name或ep.value),并让日志携带异常:except Exception as exc: self._load_errors[ep.name] = exc _logger.warning("Failed to load node plugin: %s", ep.name, exc_info=True)最小修法就是这一处:
_logger.warning(..., exc_info=True)一行即可让 traceback 进日志。 -
让失败可重试:
discover()中把self._discovered = True移到self._discover_entry_points()成功返回之后;或对扫描整体加 try,失败时复位_discovered = False再抛。同时把:174的entry_points(group=...)调用纳入 try,使importlib.metadata层面的失败与插件加载失败走同一条可观测路径。 -
错误文案带上加载失败信息:
parse_node()的 hint 分支(:139-141)在self._load_errors命中node_type时追加「该节点插件存在但加载失败:<异常摘要>」,避免把它归因为拼写错误。 -
验收条件(可执行、可断言):
- 新增单测(建议
tests/unit/test_node_registry_discovery.py):mock entry_points(side_effect=RuntimeError('boom')):第一次discover()抛RuntimeError,且断言reg._discovered is False;第二次discover()再次调用entry_points(断言call_count == 2)。mock ep.load(side_effect=ModuleNotFoundError("No module named 'kafka'")):discover()不抛,且断言reg._load_errors['delay']是该ModuleNotFoundError;用caplog断言日志记录exc_info非空(或断言日志文本包含kafka)。- 随后
reg.parse_node({'type': 'delay'})的RuntimeError文案包含plugin或加载失败原因,而非仅Available node types。
- 复现脚本(本文证据 3 / 证据 6)在修复后应变为:证据 6 的
call_count after second为 2;证据 3 的日志包含No module named 'kafka',且hasattr(reg, '_load_errors')为True。
- 新增单测(建议
边界
- 本条只处理「插件发现失败不可观测 / 不可重试 / 报错误导」这一处;不顺手重构 entry point 注册顺序、
register()的覆盖告警、内置节点集合,也不改动init_default_registry()的显式初始化语义。 - 与 #46(扩展服务启动器缺 logging 配置)不同根:#46 是服务进程根本没配 logging 导致全域静默;本条是 logging 正常、但发现阶段的异常对象与 traceback 被
except Exception:丢弃,且失败后状态位不可回退。两者修复点不同,不构成重复。 - 本次查重(
gh issue list -R jeffkit/plaita --state all --limit 100,33 条)未发现与本条同根的既有单。 - 本条为生产可达缺陷,当前触发条件是「宿主环境缺某个节点插件依赖,或插件 import 期失败」,无需额外前置说明。
- Dominant language
- Python
- Stars
- 0
- Forks
- 1
- Avg merge
- 2h 33m
- Merged PRs (30d)
- 9
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.
More from jeffkit/plaita
-
enhancement
Difficulty 4/5 1-2 days Newbie friendliness 43/100
Maintainers usually reply within 1 day
-
Difficulty 5/5 Over a week Newbie friendliness 20/100
jeffkit/plaita#305 · 4 comments ·
Maintainers usually reply within 1 day
Similar issues
-
README.zh-CN/ja/es still hard-code 501 test counts after README.md went count-freePossibly taken @Aaqibhafeezkhan claimed this today. Openbeginner documentation good first issue
Difficulty 2/5 1-3 hours Newbie friendliness 75/100
BistaDinesh03/critique#164 · 1 comment ·
Maintainers usually reply within 1 day
-
bug IO
Difficulty 2/5 1-3 hours Newbie friendliness 65/100
MrNeRF/LichtFeld-Studio#3824 ·
Maintainers usually reply within 1 day
-
bug confirmed issue
Difficulty 2/5 1-3 hours Newbie friendliness 72/100
open-webui/open-webui#33200 · 1 comment ·
Maintainers usually reply within 1 day
-
Difficulty 1/5 Under an hour Newbie friendliness 72/100
-
The changelog workflow excludes otelbot but not dependabot, so every Dependabot pull request fails the changelog checkPossibly taken @ocelotl claimed this today. Open
Difficulty 1/5 Under an hour Newbie friendliness 82/100
open-telemetry/opentelemetry-python#5825 ·
Maintainers usually reply within 2 days