fix(core): 状态快照无环/深度保护 + 节点结果按引用入库——$NODE(或任何整表根)自引用时 generator/distributed 直接 RecursionError(normal 反而成功),且 $NODE['a'] is $NODE 暴露写入别名
Maintainers usually reply within 1 day
Nobody has claimed this yet.
Assessment
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Newbie friendliness
- 25/100
Research direction
Start with _snapshot_value in plaita/core/state.py (around line 46) and its caller to_checkpoint_dict (around line 422). Reproduce with a flow s -> a(output="$NODE") -> e in normal, generator and distributed modes, then look at update_node_result (around line 516), which writes into the live dict. Done when all three modes finish without RecursionError and the aliasing checks in tests/unit/test_clean_c1_state.py pass. The cycle policy (reuse the object or raise an explicit error) needs a maintainer decision first.
Written by the indexing model from the issue text.
Description
优先级 P2 · 依赖:无
基线 HEAD = 65f48f2
Summary
把整个 $NODE 表作为 assignment 的输出(output: "$NODE")时,状态表被写回自身、变成自引用容器;快照出口
_snapshot_value 是无 memo、无环检测、无深度上限的朴素递归,于是 generator / distributed
两个模式直接 RecursionError,而 normal 反而「成功」。
(通用形态是「任何被回指的容器」,$NODE 是实测触发路径。)
证据 1(写入别名):update_node_result 把节点结果按引用塞进 $NODE
plaita/core/state.py:516-521
def update_node_result(self, node_id: str, result: Any) -> None:
"""Record a node's result under ``$NODE`` (creating the map if absent)."""
key = f"{self.prefix}{self.node_name}"
node_results = self.get(key, {})
node_results[node_id] = result
self[key] = node_results
self.get(key, {}) 取到的是 $NODE 当前live dict,result 也原样存入,无拷贝、无类型/值域判断。
调用方:plaita/core/runner.py:240
self.context.update_node_result(node, result)
(plaita/core/context.py:341-342 只是转发 self._state.update_node_result(node.id, result)。)
证据 2(根查找返回 live 对象,故 output: "$NODE" 回指状态表本身)
plaita/core/expression_parser.py:383-387
def thunk(frame: Frame) -> Any:
context, registry, prefix = frame
# Root lookup: KeyError preserved for missing keys (matches old engine)
try:
obj = context[root_key]
此处的 context 是 CheckpointState(context.py:330 把 self._state 传进表达式引擎),它的
__getitem__(state.py:332,$NODE 走 _key_to_field() 命中的字段,返回 getattr 的存储对象)
不做拷贝,所以 output: "$NODE" 取到的就是 $NODE 的 live dict。
plaita/node/assignment.py:43-45 的校验只断言非空,第二个断言恒真,不拦值域:
def validate(self):
assert self.output_type is not None, "assignment node required outputType."
assert self.output is not None or len(self.upstream_output) >= 0, "Assignment Node required output."
文档 docs-site/docs/guide/expressions.md:29 只把 $NODE.<node> 形态列为合法引用,未定义「整个 $NODE 作为节点输出」的语义:
| `$NODE.assign` / `$NODE.assign.field` | 节点 `assign` 的输出 / 其字段 | `None` |
证据 3(快照出口无环/深度保护)
plaita/core/state.py:46-66
def _snapshot_value(value: Any) -> Any:
"""递归物化 dict/list/tuple 容器为私有快照(C1-2,2026-10 评审修复包 C1)。
历史上 checkpoint/context 出口(``to_checkpoint_dict``、``$PARENT`` 快照)
是浅拷贝——``$NODE`` 等嵌套容器按引用共享,消费方(分布式宿主异步序列化、
子流程 ``$PARENT``)拿到的是 live 别名:写穿透回运行态、后续执行让已"落盘"
的数据漂移。快照只在容器边界重建;JSON 不可表达的非容器对象(与历史行为
一致)仍按引用共享——它们本就无法进入 checkpoint 序列化。
"""
if isinstance(value, dict):
return {k: _snapshot_value(v) for k, v in value.items()}
if isinstance(value, _LazyRootSnapshot):
return value.materialize()
if isinstance(value, list):
return [_snapshot_value(v) for v in value]
if isinstance(value, tuple):
return tuple(_snapshot_value(v) for v in value)
if isinstance(value, (set, frozenset)):
# 集合元素必可哈希(不可变),浅重建即快照
return value.copy()
return value
出口链路:plaita/core/context.py:367-372
def to_dict(self) -> Dict[str, Any]:
from plaita.core.state import validate_checkpoint
data = self._state.to_checkpoint_dict()
for warning in validate_checkpoint(data, self.express_prefix):
logger.warning(warning)
return data
→ plaita/core/state.py:422
return {k: _snapshot_value(self[k]) for k in self}
每个步进结果都过这个出口:plaita/core/strategies.py:190-193(generator 每步)
yield _create_lazy_output(
current, result, branch, context.to_dict(),
is_end=reached_end, execution_id=context.execution_id,
)
plaita/core/strategies.py:347(distributed 每步)
return _create_lazy_output(current_node, result, branch, context.to_dict(), execution_id=context.execution_id)
plaita/core/strategies.py:395(resume 步)
return _create_lazy_output(current_node, result, None, context.to_dict(), is_suspend=False, execution_id=context.execution_id)
复现(已实跑)
流程:s(start) → a(assignment, output="$NODE") → e(end, output="$NODE.a")。
$ /tmp/plaita-audit-venv/bin/python /tmp/repro_pcf_f5.py
=== normal ===
normal -> OK: {'s': None, 'a': {...}, 'e': {...}}
$NODE['a'] is $NODE ? True
=== generator ===
generator -> RecursionError maximum recursion depth exceeded
=== distributed ===
distributed -> FlowErrorException maximum recursion depth exceeded
cause: RecursionError maximum recursion depth exceeded
=== deep nesting (no cycle, just depth) via to_dict ===
deep 2500 -> RecursionError maximum recursion depth exceeded
distributed 的真实调用链(实跑 traceback 去重后的关键帧;末两行为归一化再抛):
plaita/core/executor.py:460 in run_distributed
plaita/core/strategies.py:273 in execute
plaita/core/strategies.py:347 in _execute_current_node
plaita/core/context.py:369 in to_dict
plaita/core/state.py:422 in to_checkpoint_dict
plaita/core/state.py:56 in _snapshot_value
plaita/core/state.py:57 in _snapshot_value → RecursionError
plaita/core/executor.py:462 in run_distributed
plaita/core/_error_normalization.py:69 in raise_distributed_error → FlowErrorException
generator 的 traceback 则是 _snapshot_value 在 state.py:56 上递归重复直至触顶(setrecursionlimit(300) 下显示为 [Previous line repeated 285 more times])。
别名在后续步进中真实写穿(已实跑,同一 $NODE 被两步回指)
流程 s → a(output="$NODE") → b(output="$NODE.a") → e,normal 模式取 $NODE:
keys: ['a', 'b', 'e', 's']
$NODE['a'] is $NODE -> True
$NODE['b'] is $NODE['a'] -> True
'b' in $NODE['a'] -> True
即 a 步记录的「节点结果」在 b 步之后凭空多了 b 键——已记录的节点结果是被引擎继续原地改写的 live 容器。
反证:既有测试完全没有覆盖
$ grep -rn "RecursionError" --include="*.py" tests/
(零命中,exit=1)
$ grep -rln "to_checkpoint" --include="*.py" tests/
tests/unit/test_clean_c1_state.py
tests/unit/test_state.py
tests/unit/test_state_mutations.py
test_clean_c1_state.py 只覆盖写入隔离(test_to_checkpoint_dict_no_write_through、test_snapshot_covers_list_tuple_dict_shapes 等),无自引用、无深嵌套用例。
影响
- 流程作者:在 assignment 的
output里写整表根(实测output: "$NODE";任何把容器写回其自身可达位置的output同型)即可触发。本地normal跑得通还返回「成功」,$NODE['a'] is $NODE为 True 时作者也看不出异常;一旦进generator(FlowExecution.run(..., mode="generator")/lazy=True,公共 API)或上集群走 worker,立刻崩。 - 集群部署(生产可达):
plaita/server/flow_worker.py:1384的execution.run_distributed(...)是 worker 的生产执行路径,每步都调context.to_dict()。自引用状态下报错被_error_normalization.raise_distributed_error归一化成FlowErrorException("maximum recursion depth exceeded"),节点代码、节点 id 一概不可见——「本地绿、上线红」且错误信息误导定位方向,一条 run 白跑。 - 深度不设限的第二类触发:即使没有环,
to_checkpoint_dict()对深嵌套值(实测 2500 层)同样RecursionError。大 payload 摘要树、递归结构体一类流程数据在 checkpoint 出口会撞同一处,且撞在「落盘/序列化」这一跳,现场只剩一个裸RecursionError。 - 别名写穿(与环独立的第二半):
update_node_result按引用入库,使任何在update_node_result之前拿到$NODE或其某键容器的消费方(callback、宿主侧序列化前的读、节点内部缓存)观察到的是持续被后续步骤改写的容器;已「记录」的历史节点结果不再稳定。 - 不修的后果:一份在本地验证通过的合法 flow 会上线即崩,崩因被归一化掩盖;同时节点结果表的引用语义没有任何契约,下游无法安全缓存任何
$NODE值。
建议
plaita/core/state.py:46的_snapshot_value加环与深度保护:用id()memo 集合检测已访问容器,命中环时按既定语义降级(复用已构建对象,或抛出带容器路径的明确异常)而不是递归到RecursionError;同时加节点计数/深度上限,超限抛可捕获的显式异常(消息里带上路径前缀),避免裸递归错误穿透到归一化层。若要保住热路径成本,可改写为显式栈的迭代物化。plaita/core/state.py:516-521的update_node_result:不要原地改写self.get(key, {})返回的 live dict,改为取出后 copy 再写回;是否对result做快照按第 1 点的语义一并决定并在 docstring 写明「入库是否按值」。- 入口收敛(可选但便宜):
plaita/node/assignment.py:43-45的validate或解析期对「output指向不带字段段的整表根」给出明确报错或 warning——$NODE.<node>才是文档承认的形态。 - 验收条件:
- 新增单测(建议放
tests/unit/test_clean_c1_state.py)构造s → a(output="$NODE") → e流程,断言normal、generator、distributed三模式均不抛RecursionError;distributed不产生FlowErrorException("maximum recursion depth exceeded")。 - 新增断言:
to_checkpoint_dict()对 2500 层嵌套值不再抛RecursionError(或抛本仓自定义异常且消息含深度/路径信息)。 - 别名回归:对
s → a(output="$NODE") → b(output="$NODE.a") → e断言$NODE['b'] is $NODE['a']为 False、'b' in $NODE['a']为 False(若判定按值入库不可行,则至少断言引擎给出明确拒绝/告警)。 - 复现脚本
repro_pcf_f5.py应当从「1 绿 2 红 + deep 红」变为四行全 OK。
- 新增单测(建议放
边界
- 不重做 C1-2 快照设计:
$PARENT的_LazyRootSnapshot惰性物化是为热路径性能留的折中(见state.py:69-88注释),本单只补环/深度保护,不改惰性策略。 - 只处理「快照出口的环与深度」+「
update_node_result的按引用入库」这一处;不动validate_checkpoint的告警逻辑,也不改表达式引擎「根查找返回 live 对象」的既有语义(那是独立的只读包装议题)。 - 与 #16(
map子流程 end 递归限制 + if 块作用域)不同根:那是图/子流程调度的递归深度限制,本单是 Python 值快照的递归,二者触发点与修复位置均不同。 - 不覆盖
plaita-nodes;不改normal模式「允许整表根作为输出」的历史行为,除非第 3 点落地为硬校验。
- Dominant language
- Python
- Stars
- 0
- Forks
- 1
- Avg merge
- 1h 55m
- Merged PRs (30d)
- 13
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
-
Difficulty 2/5 1-3 hours Newbie friendliness 84/100
Maintainers usually reply within 1 day
-
Difficulty 5/5 Over a week Newbie friendliness 35/100
Maintainers usually reply within 1 day
-
Difficulty 5/5 Over a week Newbie friendliness 45/100
Maintainers usually reply within 1 day
-
Difficulty 3/5 1-2 days Newbie friendliness 56/100
Maintainers usually reply within 1 day
-
Difficulty 4/5 3-5 days Newbie friendliness 43/100
jeffkit/plaita#345 · 1 comment ·
Maintainers usually reply within 1 day
Similar issues
-
filesystem: `file://C:/...` bucket_url is read as a UNC share named `C:` and silently lists no filesOpen
Difficulty 2/5 1-3 hours Newbie friendliness 78/100
Maintainers usually reply within 1 day
-
comp/desktop desktop/shell os/windows P3 tool/file type/bug
Difficulty 2/5 1-3 hours Newbie friendliness 76/100
NousResearch/hermes-agent#145928 ·
Maintainers usually reply within 1 day
-
integration:coding-agents
Difficulty 1/5 Under an hour Newbie friendliness 83/100
vectorize-io/hindsight#6151 ·
Maintainers usually reply within 1 day
-
bug
Difficulty 2/5 1-3 hours Newbie friendliness 78/100
FormalLanguageConstrainedPathQuerying/FLPQ_Data#174 ·
Maintainers usually reply within 1 day
-
[VLM] granite_vision preset produces empty output and runs ~3× slower with Granite Vision 3.3-2B than with Granite Vision 4.1-4BPossibly taken @prassu017 claimed this today. Openbug
Difficulty 2/5 1-3 hours Newbie friendliness 74/100
docling-project/docling#5068 · 4 comments ·
Maintainers usually reply within 1 day