The Elm Architecture中的批量任务

Lobsters Hottest 新闻

摘要

一篇详细的博客文章,关于使用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 版)。去写一个批处理任务吧。让批处理再次伟大!

相似文章

任务队列看似简单实则棘手

Lobsters Hottest

一篇探讨任务队列隐藏复杂性的技术博客文章,分析了它们为何看似简单实则棘手,并提供了系统设计的有用视角,如警惕队列、限制和故障模型。

在连续批处理中实现异步性

Hugging Face Blog

本文解释了如何为LLM推理实现异步连续批处理,将CPU批处理准备与GPU计算重叠,以最大化利用率并减少空闲时间。