图停在审核前。第二次用同一个 threadId 调用,图读到了上次留下的资料,prepare 还是又跑了一遍。如果准备节点会写库或调用外部服务,重新执行一次就有实际影响。排查时既要看旧状态有没有读回来,也要看图从哪个节点开始。
在 Graph Core 1.1.0.0-M4 里,threadId 用来找同一组检查点。普通调用可以读到旧状态,节点仍从头调度。显式续跑需要通过 addHumanFeedback(...) 进入恢复分支,再按配置定位检查点。下面的节点计数和异常来自 Temurin JDK 21.0.13+9-LTS 在 Docker 断网容器中的实跑。
先在 review 前停住
实验只放三个节点 prepare → review → write,输入固定为 ticket-42。进入节点函数时,程序就记一次节点名,所以顺序和次数都按实际调用统计。审核用固定值模拟,写入结果也只更新图的状态。
编译时注册 MemorySaver,用 interruptBefore(REVIEW) 停在 review 执行之前。保留线程记录的设置是 releaseThread(false)。读取检查点时,savedNode=prepare 表示已经执行到准备节点,nextNode=review 才是暂停后要运行的节点。
.compile(CompileConfig.builder()
.saverConfig(SaverConfig.builder().register(new MemorySaver()).build())
.releaseThread(false)
.interruptBefore(REVIEW)
.build());
每组的第一轮都在审核前停住。这是普通调用组的第一轮,review 和 write 都还没执行。
prepare.sees.prepared=-
first PAUSED order=[prepare] counts={prepare=1, review=0, write=0}
state prepared=packet:ticket-42 reviewed=- result=-
checkpoint threadId=run-plain checkPointId=40abf59f-846f-4610-a52b-0407be3fd5f0 savedNode=prepare nextNode=review
同一个 threadId 普通调用
第二次用原来的 threadId 调用,输入是空 Map。prepare.sees.prepared=packet:ticket-42 说明第二次进入 prepare 时,第一次生成的资料已经在状态里了。这轮又执行了一次 prepare,然后在 review 前停下。两轮合计,准备节点跑了两次。
call=plain threadId=run-plain checkPointId=- humanFeedback=false
prepare.sees.prepared=packet:ticket-42
plain PAUSED order=[prepare] counts={prepare=1, review=0, write=0}
state prepared=packet:ticket-42 reviewed=- result=-
显式恢复从 review 开始
这组也先从头跑到暂停点。第二次调用用原来的 threadId、刚保存的 checkPointId,再把首次返回的 InterruptionMetadata 交给 addHumanFeedback(...)。M4 在看到该元数据后会走恢复分支,读出检查点的下一节点。
return RunnableConfig.builder()
.threadId(threadId)
.checkPointId(checkpointId)
.addHumanFeedback(feedback)
.build();
这轮从 review 接着跑,review 和 write 各执行一次。最终的 result 仍包含第一次 prepare 留下的资料。
call=resume threadId=run-resume checkPointId=f219339f-c97e-4b71-bcab-0fea907e4792 humanFeedback=true
resume FINISHED order=[review, write] counts={prepare=0, review=1, write=1}
state prepared=packet:ticket-42 reviewed=approved:packet:ticket-42 result=written:approved:packet:ticket-42
只传 checkpoint ID,也可能报找不到
这一组还用原来的 threadId,再加上查得到的 checkPointId。调用配置里没有人工反馈,图就从头调度。等 prepare 重跑完,框架在保存检查点时抛出了 NoSuchElementException。
call=id-only threadId=run-id-only checkPointId=aa180c1f-9d58-4532-b6b6-f20a291cb3f2 humanFeedback=false
checkpoint.found.before.call=true
prepare.sees.prepared=packet:ticket-42
id-only ERROR order=[prepare] counts={prepare=1, review=0, write=0}
java.util.NoSuchElementException: Checkpoint with id aa180c1f-9d58-4532-b6b6-f20a291cb3f2 not found!
checkpoint.found.after.call=false
同一个 ID 在调用前能查到,报错后才查不到。对照 M4 源码和堆栈,普通启动处理 START 时已经保存了新的检查点。携带旧 checkPointId 会让 MemorySaver.put 走替换分支,旧条目被新检查点替换。等 prepare 跑完,框架又拿旧 ID 保存下一份检查点,这时才报错。排查时可以先看异常栈落在读取还是保存,别光凭错误里的 ID 判断原因。 M4 发布源码包
换一个 threadId 恢复
保留原检查点 ID 和暂停元数据,只把线程改成从未运行过的 run-without-checkpoint。这一组在检查点查找时就报错,三个节点的执行次数都是零。一个检查点 ID 不能绕过 MemorySaver 按 threadId 查找的范围。
call=other-id threadId=run-without-checkpoint checkPointId=87cfd669-a255-445f-9a48-83244b93ecbf humanFeedback=true
checkpoint.found.for.target.thread=false
other-id ERROR order=[] counts={prepare=0, review=0, write=0}
java.lang.IllegalStateException: Resume request without a valid checkpoint!
JVM 重启后,原来的两个 ID 都还在
重启对照按顺序启动两个 Java 进程。第一个进程暂停后,把 threadId、checkPointId 和暂停点关联的节点名写进临时文件,然后退出。第二个进程用相同的图配置、新的 MemorySaver 和原来的两个 ID 发起显式恢复。日志中的两个 PID 分别对应保存和恢复时的 JVM。
=== restart-save ===
java=21.0.13+9-LTS pid=142
prepare.sees.prepared=-
first PAUSED order=[prepare] counts={prepare=1, review=0, write=0}
state prepared=packet:ticket-42 reviewed=- result=-
checkpoint threadId=run-restart checkPointId=97824671-40f6-4ad8-95f2-7a7d6d657858 savedNode=prepare nextNode=review
saved identifiers; checkpoint data remains in MemorySaver
=== restart-resume ===
java=21.0.13+9-LTS pid=164
call=restart-resume threadId=run-restart checkPointId=97824671-40f6-4ad8-95f2-7a7d6d657858 humanFeedback=true
checkpoint.found.in.new.jvm=false
restart-resume ERROR order=[] counts={prepare=0, review=0, write=0}
java.lang.IllegalStateException: Resume request without a valid checkpoint!
第二个进程查不到检查点,三个节点都没执行。想做跨进程恢复,后续应单独验证对应版本支持的持久化 Checkpoint Saver,并在新进程接回同一份持久化数据。课程里的 Store 章节讲的是长期数据与命名空间,可用于理解两类存储的用途。 官方检查点文档 · 第 4 讲:Store 存储体系
这几组日志怎么对照
各组第一次都执行了 1 次 prepare,随后暂停。表里的次数从第二次调用开始计。
| 第二次调用 | 节点顺序 | prepare / review / write | 结果 |
|---|---|---|---|
原 threadId,普通调用 | prepare | 1 / 0 / 0 | 再次暂停 |
原 threadId,显式恢复 | review → write | 0 / 1 / 1 | 完成 |
原 threadId,只加 checkPointId | prepare | 1 / 0 / 0 | 保存下一检查点时抛出 NoSuchElementException |
换 threadId,显式恢复 | 空 | 0 / 0 / 0 | 找不到有效检查点 |
| 换 JVM,原 ID 显式恢复 | 空 | 0 / 0 / 0 | 新 MemorySaver 找不到检查点 |
排查续跑时,先用待恢复的配置查询检查点,再对照日志确认是否真的进入恢复分支。看到已执行节点重跑,就检查调用有没有带人工反馈,以及当前依赖究竟是哪一版。看到零节点执行和 Resume request without a valid checkpoint!,再核对 threadId、checkPointId 和保存器实例。本文的调用签名和报错都以 M4 为准。 M4 依赖坐标