不让用户只能强杀进程:LakeMind 长任务可中断工程实录
用过数据工具的人大概都熟悉这样的场景:你正在导入或物化一张远程大表,进度走得很慢,中途想停一下——点了停止按钮,页面毫无反应;再点一次,还是没反应。这时候你只剩下一个选择:强杀进程,重启应用。
这正是 LakeMind 此前面对的问题。把一张 MaxCompute 大表物化到本地,往往需要几分钟;用户在此期间点停止,应用设置的停止信号只会在 Agent 的对话轮次之间被检查,而物化本身是一个长时间运行的阻塞任务,期间根本没有轮次切换,信号永远不会被检查到——停止按钮形同虚设。
LakeMind 分三步解决了这个问题:让停止信号真正到达正在执行的操作、让后续的数据块不再启动、让中断之后的后台子进程被干净地清理。再配合进度可见与断点续传,「停止」终于变成了一个用户可以放心使用的操作。
一、停止按钮为什么失灵
要理解方案,先要看清原来的设计卡在哪里。问题有三层。
第一层,停止检查点太稀疏。LakeMind 的 Agent 循环会在对话轮次之间检查停止标记,这对打断大模型回复是够用的——轮次频繁、单轮耗时短。但物化不走对话轮次,它是一次长时间的阻塞调用,期间没有任何检查点。
第二层,任务本身是铁板一块。远程大表的全量物化在一个阻塞任务里一口气执行:从远端拉数据、写入本地数据库、循环往复直到完成。任务内部没有对外界的观察点,外面的人再怎么点停止,也传不进去。
第三层,就算停了也清理不干净。LakeMind 通过一个独立的 Java sidecar 子进程拉取 MaxCompute 数据,主进程负责接收数据并写入本地。原来的逻辑只在「流读取失败」这一条特定的错误路径上终止子进程,其他失败情形下,sidecar 会继续留在后台运行。
三层问题对应了中断工程里的三个经典教训:信号到不了执行层、循环没有检查点、清理只覆盖了部分出口。
二、第一步:让停止信号到达正在执行的操作
第一步最关键:不能只设置标记,要打断当前正在执行的数据库操作。
LakeMind 的物化通过 DuckDB 的 appender 写入数据,写入过程中始终有一个「正在执行中」的操作。新的方案把停止按钮接到了数据库引擎的中断句柄上:用户点停止时,立即触发引擎级中断,DuckDB 会打断当前的 appender 写入或 INSERT 语句,当前这一块数据的拉取随之立即终止,错误沿调用栈向上传播,整个任务退出。
这里的关键是层次的变化。标记是应用层的约定——「希望你抽空看一眼」;引擎中断句柄是执行层的介入——「现在立刻停下来」。对于长时间的阻塞操作,只有后者能把等待时间从「直到任务结束」缩短到毫秒级。
三、第二步:分块检查点,让下一块不再启动
立即中断解决了「正在跑的这一块」,但还有另一个问题:物化是一个循环,当前块被打断后,循环可能顺势进入下一个块。
好在 MAxCompute 的物化本来就是分块进行的:分区表一个分区一个分区地拉,非分区表按五十万行一个窗口拉取。新的方案在每个块启动之前检查停止标记,一旦用户已停止,直接返回,下一个块不会再启动。
于是两层防线各司其职:立即中断负责「让正在跑的停下来」,分块检查点负责「让下一块不启动」。前者保证响应快,后者保证停得彻底。任何可以分块的长任务——无论是批处理、文件抓取还是数据同步——都值得参考这个组合。
还有一个附带的好处:LakeMind 的物化支持断点续传,已经拉下来的那部分数据不会因为中断而被回滚,表状态会被标记为部分完成,下次可以继续从断点处接着拉。中断不再是「前功尽弃」的操作,用户点停止时的心理负担会小很多。
四、第三步:在所有错误路径上清理子进程
最容易遗漏的是清理问题。
中断发生后,appender 写入会失败,而这个失败落入的并不是「流读取失败」那条路径——原来的代码只在后者里终止 sidecar 子进程。结果是:用户看到前台任务停了,后台却有一个 Java 进程继续运行,占着内存和网络连接,甚至还在继续从远端拉数据。
修复的办法很直接:把拉表流程中所有可能失败的环节——建表、打开 appender、appender 写入、流读取——全部列出来,在所有错误路径上都终止 sidecar 子进程,不留例外。
这里有一条普适的启示:一个函数的错误路径会随时间增长。每加一段处理逻辑,就多一个出口;如果清理逻辑只绑定在特定错误类型上,而不是「所有出口」,迟早会漏出孤儿进程。子进程、网络连接、临时文件这类资源,清理一定要按「任何失败都清理」来写,而不是按「我当时想到的那种失败」来写。
五、题外话:长任务要先看得见
在修中断的同时,LakeMind 还顺手解决了一个相邻问题:长任务的可见性。
原来的注册与物化流程完全静默——没有日志,按钮也没有任何反馈,添加一张大表看起来就像应用卡死了。很多「强杀进程」其实不是因为停止按钮失灵,而是用户以为应用挂了。改进有三件事:在每个关键步骤写入进度日志(计数、拉数、注册、报错);运行期间给添加入口一个加载中状态;对动辄几十秒的远程查询显示实时计时器,告诉用户「它还在跑,而且已经跑了多久」。
静默是用户焦虑的最大放大器。一个长任务,先要看得见,才谈得上停得掉。
六、三点启示
回头看这次改造,有三点可以抽象出来,适用于任何长任务场景。
第一,停止按钮是工程问题,不是 UI 问题。到不了执行层的停止信号只是一个承诺。设计长任务时不妨先问一句:停止信号最远能到达哪里?如果答案只是「某个标记」,多半是不够的。
第二,中断要用两层防线:立即中断停住当下,检查点拦截未来。任务能分块,就把块边界变成天然的检查点。
第三,在所有错误路径上清理资源。错误路径会随着功能演进而增长,清理逻辑要对准「任何出口」,而不是「最初的出口」。
对一个应用来说,中断工程的质量直接决定了用户的掌控感。用户点下停止时,期待的是系统像训练有素的协作者一样:停下手头的事,不再接新活,把现场收拾干净——而不是充耳不闻,逼着用户去拔电源。
项目链接:
- 官网:https://lakemind.xi-n.com/
- GitHub:https://github.com/tsingliuwin/lakemind
