fix(expr): 内建 mutable 函数就地改写 live 执行上下文——循环条件之外的赋值/节点参数求值即破坏 $INPUT/$GLOBAL,并随 checkpoint 落盘
Nobody has claimed this yet.
Assessment
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Newbie friendliness
- 38/100
Research direction
Start by running reproduction 1 from the issue to see $INPUT.items change after $F.pop is evaluated. The mutators are in plaita/core/expression.py (around lines 224-247, registered near 349-389), and the evaluation path is ExecutionContext._evaluate_string in plaita/core/context.py. Read the loop-condition deepcopy in plaita/node/loop.py lines 60-70 and its test TestLoopConditionIsolation. Done when $INPUT/$GLOBAL stay unchanged after evaluation, the flow in reproduction 2 returns [1, 2, 3], and the existing tests still pass. Choosing between copy-on-write and evaluator-level isolation is a maintainer decision.
Written by the indexing model from the issue text.
Description
优先级 P1 · 依赖:无
基线 HEAD = 65f48f2
Summary
表达式引擎的内建「就地改写」函数($F.pop / $F.set / $F.clear / $F.setListItem …)收到的
参数是 live 执行上下文里的对象引用,不是副本。只要这类函数出现在循环条件之外的任何求值点
(assignment 节点的 output、end / http / code / child 等节点的参数求值),它就地改写的
就是 $INPUT / $GLOBAL / $NODE 本体;改写结果随后被 to_dict() / to_checkpoint_dict()
序列化进 checkpoint,恢复后仍然是脏的。求值「看起来」是纯函数调用,实际上带副作用,且没有任何
引擎侧拦截。
plaita/core/expression.py:224/:230-231/:239-241/:247—— 这 9 个函数直接对入参执行
就地操作(pop/__setitem__/update/clear),返回值是「被弹出的元素」或None,
而不是新容器:
def _fn_pop(a, b): return a.pop(b)
def _fn_delListItem(a, b): return a.pop(b)
def _fn_setListItem(a, b, c): return a.__setitem__(b, c)
def _fn_set(a, b, c): return a.update({b: c})
def _fn_delete(a, b): return a.pop(b)
def _fn_clear(a): return a.clear()
def _fn_delDictValue(a, b): return a.pop(b)
def _fn_setDictValue(a, b, c): return a.update({b: c})
def _fn_clearDict(a): return a.clear()
plaita/core/expression.py:349-355与:380-389—— 它们被打上has_side_effects=True注册,
但这只是元数据:注册表里没有任何逻辑因此改变调用方式:
_side_effect: list[tuple[str, Callable, str]] = [
("pop", _fn_pop, "Pop item at index (mutates list)"),
("delListItem", _fn_delListItem, "Delete item at index (mutates list)"),
("setListItem", _fn_setListItem, "Set item at index (mutates list)"),
]
for name, func, desc in _side_effect:
reg.register(name, func, FunctionCategory.ARRAY, has_side_effects=True, description=desc)
plaita/core/context.py:328-331——execution.evaluate(...)把self._state
(liveCheckpointState,见plaita/core/context.py:238-240的context属性直接返回self._state)
原样交给求值器:
def _evaluate_string(self, value: Any) -> Any:
return _resolve_expression_string(
self._evaluator, self._state, self.express_prefix, self.parent, value,
)
plaita/core/state.py:332-345——CheckpointState.__getitem__返回的是 live 对象引用,
不做拷贝,所以$INPUT.items取到的就是上下文里那个 list 本身:
def __getitem__(self, key: str) -> Any:
if key == "EXPRESS_PREFIX":
if "EXPRESS_PREFIX" in self._present:
return self.prefix
raise KeyError(key)
mapping = self._key_to_field()
if key in mapping:
if key in self._present:
return getattr(self, mapping[key])
raise KeyError(key)
extras = self._extras
if key in extras:
return extras[key]
raise KeyError(key)
plaita/core/state.py:516-521—— 节点结果入库同样不拷贝,$NODE.<id>里的容器也共享同一批对象:
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
- 求值点遍布各节点,都会拿到 live 上下文(仅
loop.py的条件路径例外,见第 7 条):
$ grep -rn "execution.evaluate(" --include='*.py' plaita/node/
plaita/node/assignment.py:62: evaluated = execution.evaluate(value)
plaita/node/child.py:51/58/97/104: return child_execution.run_compatible(self.child_flow, lazy, execution.evaluate(self.input))
plaita/node/code.py:1010-1012: language / code / input 三次求值
plaita/node/concurrent.py:158-159: execution.evaluate(input_key) / execution.evaluate(pb.input)
plaita/node/end.py:65: return execution.evaluate(self.output)
plaita/node/event_node.py:105: resolved = execution.evaluate(self.event_type)
plaita/node/http.py:738: return execution.evaluate(self.output)
plaita/node/loop.py:188/410/427: collection / initial 求值
plaita/node/redis.py:32/53-54: target / arguments / command 求值
plaita/node/loop.py:60-70的注释自己承认了这个风险,但只在循环条件这一处做了隔离;
has_side_effects的实际用途全仓也只有这一个点和一个查询方法:
# 表达式引擎有 ``$F.set`` / ``$F.pop`` / ``$F.clear`` 等 mutate 函数(见
# core/expression.py,has_side_effects=True),条件引用它们时只能改到副本,
# 不污染原 context(契约钉在 tests/unit/test_loop.py::TestLoopConditionIsolation)。
反证「没有引擎侧拦截」—— has_side_effects 的全部非注册用法:
$ grep -rn "side_effect" --include='*.py' plaita/ | grep -v 注册/描述行
plaita/core/expression.py:139: def side_effect_functions(self) -> List[FunctionDescriptor]:
plaita/node/loop.py:69:# core/expression.py,has_side_effects=True),条件引用它们时只能改到副本,
即:只有「一个查询方法」+「一处注释」。求值器在调用函数前不检查该标志,也不复制参数。
复现 1(实跑,/tmp/plaita-audit-venv/bin/python,Python 3.12,plaita editable 安装)——
live 上下文与 checkpoint 同时被改写:
$ cd /tmp && /tmp/plaita-audit-venv/bin/python - <<'EOF'
from plaita.core.context import ExecutionContext
from plaita.core.expression_parser import ExpressionParser
ctx = ExecutionContext()
class F:
expose_env=None; global_context={}; flow_id='f1'
ctx.setup_flow(F(), ({},), {})
ctx.set_state('$INPUT', {'items':[1,2,3]})
p = ExpressionParser.for_prefix('$')
print('before:', ctx.get_state('$INPUT'))
print('pop result:', p.evaluate('{% $F.pop($INPUT.items, 0) %}', ctx.context))
print('after live:', ctx.get_state('$INPUT'))
print('checkpoint $INPUT:', ctx.to_dict()['$INPUT'])
EOF
--- 输出 ---
before: {'items': [1, 2, 3]}
pop result: 1
after live: {'items': [2, 3]}
checkpoint $INPUT: {'items': [2, 3]}
复现 1 补充(同一解释器)—— setListItem / clearDict 返回 None 但元素/字典已被改掉,
调用方拿不到任何「发生了写入」的信号:
setListItem returns: None
after setListItem live $INPUT: {'items': [99, 2, 3]}
clearDict returns: None
after clearDict $GLOBAL: {'cfg': {}}
复现 2(实跑)—— 端到端流程:assignment 节点的 output 只要求值,就把 $GLOBAL 吃掉了,
整条 flow 的返回值也随之变化:
$ cd /tmp && /tmp/plaita-audit-venv/bin/python - <<'EOF'
from plaita.core.executor import FlowExecution
from plaita.core.flow import Flow
flow = Flow.model_validate({'flow_id':'mutprobe2','name':'p','global_context':{'items':[1,2,3]},'nodes':[
{'id':'s','type':'start','next':'a1'},
{'id':'a1','type':'assignment','output':'{% $F.pop($GLOBAL.items, 0) %}','next':'end'},
{'id':'end','type':'end','output':'$GLOBAL.items'}]})
print('flow result:', FlowExecution().run(flow, {}), ' (纯函数语义应为 [1,2,3])')
EOF
--- 输出 ---
flow result: [2, 3] (纯函数语义应为 [1,2,3])
复现 3(实跑)—— checkpoint 落盘确认:用 lazy 逐节点输出(strategies.py 里就是
context.to_dict() 的产物)观察每个节点结束时的 checkpoint 快照,assignment 求值之后
落盘的 $GLOBAL 已经是 [2, 3]:
$ cd /tmp && /tmp/plaita-audit-venv/bin/python - <<'EOF'
from plaita.core.executor import FlowExecution
from plaita.core.flow import Flow
flow = Flow.model_validate({'flow_id':'mutprobe7','name':'p','global_context':{'items':[1,2,3]},'nodes':[
{'id':'s','type':'start','next':'a1'},
{'id':'a1','type':'assignment','output':'{% $F.pop($GLOBAL.items, 0) %}','next':'end'},
{'id':'end','type':'end','output':'$GLOBAL.items'}]})
fe = FlowExecution()
for out in fe.run_compatible(flow, True, {}):
d = out if isinstance(out, dict) else getattr(out, '__dict__', {})
ctx = d.get('context') if isinstance(d, dict) else None
if ctx: print(ctx.get('$GLOBAL'))
EOF
--- 输出 ---
{'items': [1, 2, 3], 'flow_id': 'mutprobe7'}
{'items': [2, 3], 'flow_id': 'mutprobe7'} # assignment 求值之后
{'items': [2, 3], 'flow_id': 'mutprobe7'}
反证「只有循环条件被保护」:现有测试只钉了循环条件这一处的隔离契约,没有任何测试断言
「求值后原容器不变」:
$ grep -rn "F.set\|F.pop\|F.clear\|setListItem" tests/
tests/unit/test_expression.py # 只断言 has_side_effects 元数据
tests/unit/test_io.py
tests/unit/test_loop.py:133-140 # TestLoopConditionIsolation
tests/unit/test_mfix_l_loop_context.py:198-225 # 循环条件隔离($F.set 不得污染 $GLOBAL)
影响
- 谁踩到:任何写了 mutate 函数但没意识到它就地改写上下文的流程作者,以及用
@flow/ IR
生成的流程。$GLOBAL是流程级共享状态、$INPUT是入口数据,表达式里出现
{% $F.pop($GLOBAL.items, 0) %}这种「取第一个元素」的直觉写法就会命中——从函数名与返回值
看,它与纯函数的$F.getListItem/$F.slice长得几乎一样,没有任何报错或告警提示副作用。 - 触发点不止循环条件:
assignment(output)、end/http(output)、code
(language/code/input)、child(input)、concurrent、redis、event_node的
参数求值都会拿到 live 上下文。循环条件有deepcopy隔离(loop.py:60-70),其他点全部裸奔。 - 代价,按严重度排序:
- 数据错/丢失:
$INPUT/$GLOBAL被静默改写。同一个容器被多个节点续用时会累积漂移;
同一个$GLOBAL.items在第二次求值$F.pop时弹出的是「已经少了一个元素」的列表。 - 随 checkpoint 落盘并跨恢复存活:改写后的容器被
to_dict()序列化(复现 1 与复现 3),
执行中断 / 挂起 / 恢复 / worker 重启后脏数据仍在,重试不会自愈。 - 不可观测:
setListItem/clearDict/set返回None;pop返回的是被弹出的元素
(看起来像一次正常的取值),作者拿不到「上下文被改了」的任何信号。 - 并发写风险:
plaita/core/expression.py:358-367的_register_dictdocstring 自己写明这类
函数「not thread-safe —— 并行节点执行 / 跨 async 任务共享上下文时可能损坏数据」,
而当前引擎并未替调用者加锁或复制(docstring 把责任推给调用者,但调用者是求值器本身)。
- 数据错/丢失:
- 生产可达:可达,无需特殊部署档位。任何在非循环条件位置写 mutate 表达式的流程即可命中
(复现 2 是 3 个节点、一条 assignment 的最小流程)。 - 不修的后果:表达式求值语义「看起来纯、实际带写副作用」这一条会长期作为静默数据损坏源存在;
checkpoint 会把脏状态固化下来,恢复/重试都无法回到正确状态。
建议
- 修复方向(具体到文件/函数):
- 方案 A(推荐,语义最干净):在
plaita/core/expression.py把 9 个 mutate 函数改成
copy-on-write 语义——先复制入参再操作并返回新对象:
_fn_pop(a, b)→ 复制a、pop后返回「新容器」或按现有契约返回被弹元素;
_fn_setListItem/_fn_set/_fn_setDictValue同样先copy再写。这样与
_fn_append/_fn_extend/_fn_insert(已经返回新列表,见expression.py:221-223)一致,
也消除了 docstring 里的线程安全告警前提。 - 方案 B(最小改动,保留原地语义):在求值侧统一隔离——
ExecutionContext._evaluate_string
(plaita/core/context.py:328-331)在把self._state交给求值器前,对表达式静态引用到的根键
做与plaita/node/loop.py:139-158相同的「根键 deepcopy 替换」,把 loop 的局部补丁提升为
引擎级契约;loop 自己的隔离逻辑随之可以删除,避免两处语义分叉。 - 无论选哪个方案,
has_side_effects=True都应当从「元数据」变成「被求值器消费的契约」:
求值器在调用带该标志的函数时必须保证参数是副本,而不是只提供一个side_effect_functions()
查询方法(expression.py:139-141)供人自查。
- 方案 A(推荐,语义最干净):在
- 最小修法(一处):在
_evaluate_string里对 mutate 类调用做一次入参浅/深拷贝即可掐掉
「assignment节点 output 吃掉$GLOBAL」这条最短路径(复现 2);copy的粒度按容器层级
选择即可,不必引入通用快照框架。 - 验收条件(可执行、可断言):
- 新增单测(可放在
tests/unit/test_loop.py的TestLoopConditionIsolation附近,或新建
TestMutatorContextIsolation):对$INPUT.items求值{% $F.pop($INPUT.items, 0) %}
之后,断言execution.context与to_dict()里的$INPUT.items仍是[1, 2, 3];
对$F.clearDict($GLOBAL.cfg)/$F.setListItem($INPUT.items, 0, 99)做同样的不变式断言。 - 端到端回归:复现 2 的 flow 返回值必须变回
[1, 2, 3](当前是[2, 3]);
复现 3 中每一步落盘的$GLOBAL必须恒为{'items': [1, 2, 3]}。 - 反向用例不得被误伤:
$F.pop作为「取值」的返回值语义(复现 1 里返回1)、
tests/unit/test_mfix_l_loop_context.py:198-225的循环条件隔离契约、
以及tests/unit/test_expression.py里关于has_side_effects元数据的断言都必须继续通过。
- 新增单测(可放在
边界
- 本单只处理内建 mutable 函数就地改写 live 执行上下文这一条根因(对象引用共享 + 求值器不隔离)。
不改表达式解析/编译架构,不引入通用配额或快照系统,不改$F.pop等函数「返回被弹出元素」的
返回值契约(方案 A 若改变返回值需在本单内明确并同步测试),也不顺手重构loop.py的热路径优化。 - 与相邻 issue 的差异(查重结论):
gh issue list -R jeffkit/plaita --state all --limit 100
共 33 条,逐条比对后无同根条目。#23(多 worker 下 start 消息被 XCLAIM 重派导致整条 flow
双跑)与#36(flow_hash 漂移把在途 run 批量终态化)都在 worker/队列调度层,最终表现为
「重复执行」而非「容器被就地改写」;#30/#41在子进程 env/资源限制层;
本单的写入发生在 Python 求值器内部、对同一进程里的 live 上下文对象生效,修复面不重叠,故非重复。 - 不顺手扩大范围:循环条件已有的隔离补丁(
loop.py)当前工作正常,本单不要求重写它;
若采用方案 B,把该逻辑上提是消除重复语义的必要步骤,但不构成对loop.py其他行为的改动要求。
- 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
-
bug
Difficulty 2/5 1-3 hours Newbie friendliness 79/100
Maintainers usually reply within 1 day
-
Difficulty 2/5 1-3 hours Newbie friendliness 78/100
mohitmishra786/readprism#322 ·
Maintainers usually reply within 1 day
-
good first issue
Difficulty 2/5 1-3 hours Newbie friendliness 76/100
-
Difficulty 1/5 Under an hour Newbie friendliness 75/100
Maintainers usually reply within 1 day
-
API-key credentials accept a blank key: empty via the API, spaces-only via the builder's Add API key dialogPossibly taken A pull request linked to this issue is open or already merged. Open
Difficulty 2/5 1-3 hours Newbie friendliness 77/100
Significant-Gravitas/AutoGPT#15727 ·
Maintainers usually reply within 1 day