The Elm Architecture中的批量任务
摘要
一篇详细的博客文章,关于使用elm-run在The Elm Architecture中实现批量任务,重点关注状态机和类型安全转换。
<p><a href="https://lobste.rs/s/ykabry/batch_job_elm_architecture">评论</a></p>
查看缓存全文
缓存时间: 2026/07/09 19:41
# 一个批处理任务,采用 Elm 架构
来源:https://cekrem.github.io/posts/elm-run-batch-job/
在我那篇关于原生 Elm 的文章(https://cekrem.github.io/posts/native-elm/)结尾,我曾说希望下一个`elm-run`项目能是一个 REST API,或者替换掉某个小型现有应用。我稍微撒了个谎。我走了另一个方向,抓了一个大得多的东西:一个真正的批处理任务,运行在我实际构建的一个真实(且相当庞大)的应用中。获取数据的 PoC 只有 80 行最普通的 Elm 代码。但这次可不是玩具了,这正是关键所在——Damir(他正在构建 elm-run)需要有人在大规模场景下使用它,并报告哪里有问题,所以我一直在做这件事。
在开始之前先坦白:这个批处理任务*能工作*,逻辑正确,类型优美,但它还称不上“生产级吞吐量”(不过比你想象的要接近!)。我会达到那个目标(或者说 elm-run 会达到)。我想讨论的不是速度,而是它的*形态*。
## 我之前做过 CLI 的事情
链接到标题(https://cekrem.github.io/posts/elm-run-batch-job/#ive-done-the-cli-thing-before)
这并非我在 Elm 中第一次涉足批处理类工作。二月份我写了 blog-bot(https://cekrem.github.io/posts/blog-bot/),一个每天在 GitHub Actions 上运行的小型 Bluesky 发帖工具,使用了 elm-pages(https://elm-pages.com/)的 Script 模式。那体验真的很棒。`BackendTask` 将五个步骤串联起来,编译器捕捉了我的错误,我只提交了少量代码就部署了整个东西。
但是 elm-pages Script 是一种*管道*形态。你组合任务,用 `andThen` 一路走到终点,底层是 Node 在运行。非常适合“读取 RSS、转换、发布、完成”的场景。它*不是* Elm 架构。没有 `Model`,没有 `update`,没有长期运行的循环以及随时间流入流出的副作用。对于一次性执行的管道,你并不会怀念这些。但对于一个需要照看数百个任务、每个任务以自身节奏穿过多个状态、有些中途失败需要推一把的作业,你会非常怀念这些。
这正是原生 Elm 给我的区别。不是“Elm 可以运行脚本”(elm-pages 已经做到了,而且做得相当漂亮)。而是“Elm 可以运行*事件循环*——`init`、`update`、`subscriptions`——针对操作系统而非浏览器”。这个循环我之前为各种小部件写过上百次。现在它正在处理后台工作。
## 每个项都是一个小型状态机
链接到标题(https://cekrem.github.io/posts/elm-run-batch-job/#each-item-is-a-tiny-state-machine)
对一组项的批处理,本质上是一系列小的生命周期,而生命周期正是联合类型所*擅长*表达的。因此,与其使用一个包含 `status : String` 和一大堆可为空字段的记录(这是每个无类型 worker 最终都会腐烂成的形态),不如让每个项这样表示:
```elm
type Item
= Queued Input
| Submitted Ref
| Processing Ref Partial
| Finished Ref Result
| Failed Item Http.Error
```
这*不仅仅像*面向铁路编程(railway-oriented programming)。它*就是*面向铁路编程,只不过铁轨上多了几个车站。Scott Wlaschin 的 `Result`——大家常画的两状态成功/失败铁路——是这种方式的退化情况。增加中间站点,你就得到了同一条更长的轨道。(我之前写过关于解析而非验证的文章(https://cekrem.github.io/posts/parse-dont-validate-typescript/);这里是相同的直觉,但指向的是*过程*而非*值*。)
而转换是函数,它们的类型不允许你作弊。将 `Submitted` 变为 `Processing` 的步骤,根本无法接收一个 `Queued` 项。你不能为从未提交过的项获取结果,因为获取结果的函数需要一个 `Ref`,而 `Queued` 项还没有 `Ref`。我一行守卫代码都没写。这是形态的产物。不仅非法*值*不可表示,非法的*顺序*也变得不可表示。
## 记住自己站在哪里的失败
链接到标题(https://cekrem.github.io/posts/elm-run-batch-job/#a-failure-that-remembers-where-it-was-standing)
再看最后一个分支:
`Failed` 携带了完整的上一个 `Item`。不是漂浮在虚空中的错误代码——而是当 HTTP 调用放弃时,项所处的最后一个好状态。这意味着回退一个失败变得异常简单:
```elm
rewind : Item -> Item
rewind item =
case item of
Failed previous _ ->
previous
_ ->
item
```
抛出的异常是健忘的。它只知道自己*失败了*,却不知道*当时你站在哪里*。所以你会从起始点重启,重新运行那三个已经成功但昂贵的调用。而在这里,失败是一个带有返回地址的检查点。重试不是“重新开始,祈祷幂等”——而是“从你停靠的侧线恢复”。类型字面意义上将出错前的状态交还给你。
在请求/响应处理程序中,你几乎可以摆脱这种情况。一个请求失败,用户刷新页面,没人注意。而在处理数百个项的批处理中,第 287 项*一定会*失败,“作业在 64% 处崩溃”是一场灾难,而“283 完成,4 个停靠,这是它们的状态”不过是普通的一天。批处理正是“异常”与“检查点”之间的区别不再是学术问题的地方。
启动整个列表也是同样的 TEA 反射——将输入映射为待处理项,批量发送命令:
```elm
start : Config -> List Input -> ( Dict Id Item, Cmd Msg )
start config inputs =
let
pending =
inputs |> List.map (\input -> ( idOf input, Queued input ))
cmds =
pending
|> List.map (\( id, item ) -> step config (GotStep id) item)
|> Cmd.batch
in
( Dict.fromList pending, cmds )
```
然后 `update` 捕获每个 `GotStep id newItem`,将其放回 `Dict`,并为该项触发下一步 `step`。数百条独立的小铁路,全部通过同一个 `update` 函数推进,结果以消息的形式回流。这就是我多年前从零开始构建的 testimonials 小部件循环(https://cekrem.github.io/posts/starting-small-with-elm-a-widget-approach/),只不过现在它是在处理积压任务,而不是渲染一个轮播图。
## 为什么这对批处理比对前端更重要
链接到标题(https://cekrem.github.io/posts/elm-run-batch-job/#why-this-matters-more-for-a-batch-than-a-frontend)
在大多数技术栈中,批处理任务是可靠性消亡的地方。不是因为工作本身难——而是因为没有人愿意在那里投资类型。前端得到良好待遇是因为用户*能看见*它。而 worker 则是一个 bash 脚本调用一个 Python 脚本,再 shell 出去执行 `psql`,里面可能有某人在凌晨 2 点添加的 `try/except: pass`,重试逻辑大概只完成了 60%。最需要安全性的工作负载,运行在最缺乏安全性的环境中。
所以优势不在于 elm-run 有魔法。而在于批处理突然变成了一个一等公民,用同样的语言、同样的穷尽 `case` 匹配来编写,就像我真正认真对待的应用部分一样。UI 中未处理的分支是一个卡住的旋转器——烦人、可见、可恢复。批处理中未处理的分支则是在第 40,000 行静默跳过,直到一个月后的对账才被人发现。穷尽性在没有人盯着屏幕的地方回报最高。
而重构不再可怕。改变一个步骤的形态,编译器会带我遍历所有产生或消费它的地方,包括重试和回退。`update` 是一个从 `(state, message)` 到新状态的全函数,所以我可以喂给它一串记录的消息序列,并断言最终状态——重放失败、测试从检查点恢复——完全不需要触碰 IO。相比之下,重构一个有状态的命令式 worker,主要就是盯着代码祈祷它还能正确恢复。(这种测试态度我在书的测试章节(https://cekrem.github.io/posts/elm-book-testing-strategies/)中详细阐述过,现在应用于一个 cron 任务。)
## 真正令人兴奋的部分:一个领域,三个运行时
链接到标题(https://cekrem.github.io/posts/elm-run-batch-job/#the-actually-exciting-part-one-domain-with-three-runtimes)
想象一下(如果你能的话):
相同的 Elm 类型——相同的编码器、解码器、相同的领域逻辑——现在在三个地方运行。有一个 Lamdera(https://lamdera.com/)前端*和*后端,我几周前因为前端和后端已经共享类型而彻底喜欢上了它(https://cekrem.github.io/posts/greentype-lamdera/)(一个消息*就是一个*值,编译器坚持两端都要处理它)。而现在,借助 elm-run,又多了一个原生批处理二进制文件,导入完全相同的领域模块。
这消除的是整整*一类*错误。“我们推送了一个后端变更,API 形态漂移,三周后 nightly 批处理开始静默写入垃圾数据”——这不是一次事故,而是一种反复出现的事故形态,每个无类型边界都在为此永远支付租金。当解码器*字面意义上*是三个地方引用的*同一个值*时,漂移就不可能发生。没有客户端和 worker 各自“应该”匹配不同分支的规范。只有一个函数。它们调用它。编译器在它们不一致时拒绝构建。
当然,只有三个上下文真正共享一个领域时,这才能带来好处。如果你的批处理世界与前端世界大部分无关,共享类型不过是耦合了三件本来想独立的事物。在我这里,它们是同一个应用的不同角色,所以这是真实的。你的情况,一如既往,可能不同。
## 当前进展
链接到标题(https://cekrem.github.io/posts/elm-run-batch-job/#where-its-at)
和上次一样诚实:elm-run 还很年轻,而我是那个大规模场景下的小白鼠,把我遇到的粗糙边缘反馈给 Damir。这个批处理不一定快。至少目前不是。有些部分是用程序世界的胶带勉强粘在一起的 ¯\_(ツ)_/¯。但这些都不是有趣的部分。
有趣的部分比这个 beta 版本还要老,而且是我一再回来的那个赌注:Elm 从不让你绕过运行时。事实证明,这正是为一个浏览器小部件编写的 `Cmd` 能够重新定向到处理积压任务的原生二进制文件——无需改动一行代码——的唯一原因。`Cmd` 始终是副作用的*描述*,而非副作用本身。Damir 只是编写了一个新的运行时来针对操作系统执行这些描述。我的代码不知道。我的 `update` 不知道。驱动按钮点击的领域,此刻正在处理数百个任务,也是 Lamdera 后端在其间依赖的同一个领域。
这对我来说仍然感觉有点违规,但以最好的方式。
如果你也想参与,可以在 elm-run.dev(https://elm-run.dev/)了解更多(在 elm-run.dev/beta(https://elm-run.dev/beta)注册 beta 版)。去写一个批处理任务吧。让批处理再次伟大!
相似文章
任务队列看似简单实则棘手
一篇探讨任务队列隐藏复杂性的技术博客文章,分析了它们为何看似简单实则棘手,并提供了系统设计的有用视角,如警惕队列、限制和故障模型。
@pallavishekhar_: LLM中的连续批处理 阅读:https://outcomeschool.com/blog/continuous-batching-in-llms…
一篇介绍连续批处理的博客文章,该技术通过动态地将新请求添加到已完成请求的批次中,持续保持GPU忙碌并减少空闲时间,从而提高LLM服务吞吐量。
@akshay_pachaar: LLM推理中的批处理策略,清晰解释!(收藏它)- 静态 - 动态 - 和连续批处理 I w…
一篇解释LLM推理中静态、动态和连续批处理策略的文章,以及为什么提供LLM服务与传统ML推理不同。
Elm 0.19.2 带来更快的构建
Elm 0.19.2 带来了更快的构建时间,改善了开发者体验。
在连续批处理中实现异步性
本文解释了如何为LLM推理实现异步连续批处理,将CPU批处理准备与GPU计算重叠,以最大化利用率并减少空闲时间。