Rails 8.2 Active Job 新特性与迁移要点全解析:连续执行、类型化属性、事务后入队与适配器策略调整
Rails 8.2 Active Job 新特性与迁移要点全解析:连续执行、类型化属性、事务后入队与适配器策略调整
Active Job 是 Ruby on Rails 的统一任务队列抽象层,为后台任务提供一套与具体队列后端解耦的编程接口。本文以 activejob/CHANGELOG.md 中记录的 Rails 8.2 系列变更(对应当前仓库 RAILS_VERSION 为 8.2.0.alpha)为主线,逐项讲解断点续执行(Continuable)、任务参数反序列化错误细分、retry_on 参数扩展、事务提交后再入队等核心能力,并结合 activejob/lib/active_job 下的源码实现与 activejob/test/cases 中的测试用例给出佐证。读完本文,你将掌握这些新 API 的用法、背后的实现原理,以及升级到 Rails 8.2 时关于内置队列适配器(queue_classic、resque、delayed_job、backburner、sneakers、sidekiq)必须知晓的迁移路径。
连续执行(Continuations)的增强与中断语义澄清
续传游标(Cursor)序列化修复:恢复执行时拿到的是对象而非 JSON 字符串
Rails 8.2 修复了续传步骤游标(continuation step cursor)在任务被中断并恢复时会丢失类型的问题。此前游标以原始 JSON 字符串形式存留,恢复后的步骤无法看到与中断前一致的对象;现在游标与任务参数一样经过序列化,恢复执行时游标会还原为中断前设置的对象,例如一个 Date、Time,甚至是一个 Active Record 对象。
对应实现位于 continuable.rb 的 serialize / deserialize 钩子,以及 continuation.rb 的初始化逻辑。关键点在 Continuation#initialize 恢复当前步骤游标时:
@current = new_step(name, Arguments.deserialize([cursor]).first, resumed: true)
Arguments.deserialize 会递归还原 _aj_globalid(Global ID)、自定义序列化对象等编码,因此通过 step.advance! from: record.id 或 step.set! 写入的游标(完整用法见 continuation.rb 对 set!、advance!、from: 的说明),在续传时能被还原为对应的整数、时间对象或模型记录,供 find_each(start: step.cursor) 这类「从上次位置继续」的迭代继续使用。对应的测试覆盖位于 continuation_test.rb。
队列适配器 stopping? 回调签名升级:可检查任务并返回中断原因
为了在检查点(checkpoint)精确判断是否中断,队列适配器现在可以拿到具体的任务对象,并可选地返回一个中断原因。根据 continuation.rb 的文档,检查点会调用 queue_adapter.stopping?(job):
- 返回
true时,Active Job 使用:stopping作为中断原因; - 返回其他 truthy 值时,该值本身会作为中断原因传递;
- 返回
false/nil则不中断。
适配器在实现上需要升级签名以兼容新旧版本:stopping? 必须把 job 作为可选位置参数,即写为 def stopping?(job = nil)。仓库中 abstract_adapter.rb 已按此签名提供默认实现。该值最终流入 continuable.rb 的 checkpoint! / interrupt!:
def checkpoint!
if (reason = queue_adapter.stopping?(self))
reason = :stopping if reason == true
interrupt!(reason: reason)
end
end
需要留意:任务不会在适配器标记停止时立刻被打断,而是一直运行到下一个检查点或进程停止(自动检查点出现在每个 step(首个除外)开始前,以及 set!、advance!、checkpoint! 之后),因此文档建议任务设置检查点的频率应高于优雅停机超时时间,以保证安全重启。
ActiveJob::Attributes:跨序列化持久化的类型化属性
Rails 8.2 新增 ActiveJob::Attributes,用于声明在任务序列化与反序列化之间持续存在的类型化属性。它已被 continuable.rb 的 ActiveJob::Continuable 自动包含(include ActiveJob::Attributes),但也可单独使用。
实现上它复用了 Active Model Attributes API,见 attributes.rb:include ActiveModel::Attributes 后即可用 attribute :name, :type 声明带类型与默认值的属性;serialize 会把所有已声明属性的当前值以 Arguments.serialize 方式并入任务数据(键名 "attributes"),deserialize 再经 Arguments.deserialize 还原并写入对应属性槽。因为值按 Active Job 参数规则序列化,所以所有内置 Active Model 类型都受支持,自定义类型值则需能序列化为 Active Job 参数(支持范围见 arguments.rb 的文档与 arguments.rb 的实现)。
典型场景是配合 ActiveJob::Continuable:任务可能被多次中断与恢复,而前一个步骤产出的中间结果必须在后续步骤中可用。CHANGELOG 给出的完整示例(与 attributes.rb 中的文档示例一致):
class SubmitEnrollmentJob < ApplicationJob
include ActiveJob::Continuable
attribute :payment_token, :string
attribute :billing_profile_id, :integer
def perform(enrollment)
step(:tokenize_payment_instrument) do
self.payment_token = PaymentGateway.tokenize(enrollment.user.payment_instrument)
end
step(:create_billing_profile) do
self.billing_profile_id = BillingProfileApi.create(customer_id: enrollment.user_id)
end
step(:submit_enrollment) do
submission_id = EnrollmentApi.submit(enrollment, billing_profile_id)
enrollment.update!(status: 'processing', submission_id: submission_id)
end
end
end
不再需要手动覆写 serialize / deserialize。属性在断点被序列化、任务恢复时自动还原,因此跨步骤的中间状态(如上例的支付令牌与计费档案 ID)不会因重试或续跑丢失。相关行为测试见 attributes_test.rb。
异常处理:参数反序列化错误细分与 retry_on 扩展
新增 ActiveJob::DeserializationError::RecordNotFound:精确丢弃「记录已不存在」的任务
这是 Rails 8.2 异常语义的一次重要细化。此前只要参数反序列化期间发生任何错误,统一抛出父类 ActiveJob::DeserializationError(见 arguments.rb)。但它包含两类性质完全不同的失败:
- 瞬时性错误(例如数据库连接抖动)——此时引用的记录可能依然存在,重试有机会成功;
- 记录永久缺失——参数通过 Global ID 引用的记录(最可能是入队后被人删除)已不存在,重试没有意义。
若仅凭 DeserializationError 就丢弃任务,很可能误丢大量本可正常完成的任务。新引入的子类 RecordNotFound 仅在「Global ID 引用的记录不存在」这一种情况下抛出,使任务可以精准丢弃:
discard_on ActiveJob::DeserializationError::RecordNotFound
discard_on 的详细语义(含 report: 选项、块回调、以及异常处理器自下而上、沿类层级搜索的匹配规则)见 exceptions.rb。
抛错路径实现于 arguments.rb:Arguments.deserialize 捕获 GlobalID::Locator::RecordNotFound 时转抛 DeserializationError::RecordNotFound,其余异常统一转抛父类:
def deserialize(arguments)
arguments.map { |argument| deserialize_argument(argument) }
rescue GlobalID::Locator::RecordNotFound
raise DeserializationError::RecordNotFound
rescue
raise DeserializationError
end
同时请注意两个迁移要点:
- 记录在参数反序列化阶段缺失时,不再报告为
ActiveRecord::RecordNotFound。此前依赖discard_on ActiveRecord::RecordNotFound丢弃此类任务的,应改为丢弃ActiveJob::DeserializationError::RecordNotFound; ActiveRecord::RecordNotFound仍适用于任务运行期间记录才消失的场景(例如反序列化成功后、在perform内record.reload抛错)。
对父类的既有处理器不受影响、继续生效。测试见 argument_serialization_test.rb。
retry_on 的 :wait 支持 Float 与错误对象入参
支持 Float 延迟:retry_on 的 :wait 现在接受 Float,与 ActiveJob.set(wait: 1.5) 保持一致。此外,传入不支持的类型会在加载任务类时直接抛出异常。类型白名单实现在 exceptions.rb,支持 :polynomially_longer、Integer、Float、ActiveSupport::Duration、Proc,否则抛出:
raise ArgumentError, "Unsupported argument type for :wait, expected an Integer, Float, " \
"ActiveSupport::Duration, Proc, or :polynomially_longer, but got #{wait.inspect}"
determine_delay(exceptions.rb)对 Float/Integer/Duration 统一按秒计算,并叠加按 :jitter(默认 15%)随机化的抖动;:polynomially_longer 采用多项式退避 executions**4 + jitter + 2。
wait Proc 可接收错误对象:wait 的 Proc 现在允许接收第二个参数 error。arity 为 1 的 Proc 依旧只收到执行次数。分派逻辑在 exceptions.rb:
algorithm.arity == 2 || algorithm.arity < -1 ? algorithm.call(executions, error) : algorithm.call(executions)
即只有明确声明接收两个参数(或可变参数)的 Proc 才会传入 error。CHANGELOG 给出的示例:
class RemoteServiceJob < ActiveJob::Base
retry_on CustomError, wait: ->(executions, error) { error.retry_after || executions * 2 }
def perform
# ...
end
end
retry_on 的完整选项(:attempts、:queue、:priority、:jitter、:report)与块回调语义见 exceptions.rb。
入队时机:事务提交后再入队(Enqueue After Transaction Commit)
Rails 8.2 默认在数据库事务提交后才真正把任务投递到队列。此前任务在事务内、尚未提交时就被执行端消费,可能读到未提交甚至最终回滚的记录。
- 新建的 Rails 8.2 应用(以及升级到
config.load_defaults "8.2"的应用)默认启用config.active_job.enqueue_after_transaction_commit = true; - 其他应用可在
config/initializers/new_framework_defaults_8_2.rb中取消该行注释以显式启用(即选择加入)。
实现位于 enqueue_after_transaction_commit.rb:raw_enqueue 在启用配置时把真实入队动作推迟到 ActiveRecord.after_all_transactions_commit 回调中;ActiveJobMethods#perform_all_later 则先按任务的类配置把入队任务划分为「延迟组」与「立即组」,延迟组在全部事务提交后统一入队。
同时,全局配置 config.active_job.enqueue_after_transaction_commit 被取消弃用:它曾在 Rails 8.0 被弃用(当时移除了 symbol 取值)、8.1 中失效,8.2 起重新以布尔值生效,可用于全应用范围的覆盖。注意当前默认值机制同时支持配置级默认与每个任务类独立覆盖,可结合 railtie.rb 中对该配置的加载逻辑确认生效时机。
队列适配器策略调整:内置适配器清理与迁移路线
Rails 8.2 对本仓库内置的第三方队列适配器进行了一轮大清理,原则是让各队列 gem 维护自己的适配器。以下是逐项调整与对应的迁移动作:
| 适配器 | 状态 | 迁移动作 |
|---|---|---|
sidekiq |
移除(8.2 前已弃用) | 该适配器已移至 sidekiq gem 内,直接使用 sidekiq gem 提供的适配器 |
resque |
弃用 | 升级到 resque 3.0 或更高,改用 resque gem 自带的适配器 |
delayed_job |
弃用 | 升级到 delayed_job 4.2.0 或更高,改用 delayed_job gem 自带的适配器 |
backburner |
弃用 | 使用 backburner gem 提供的适配器 |
sneakers |
弃用 | 使用 sneakers gem 提供的适配器 |
queue_classic |
弃用 | 使用 queue_classic gem 提供的适配器 |
从仓库源码结构看,sidekiq 适配器文件已从 queue_adapters 目录中移除,而 backburner_adapter.rb、delayed_job_adapter.rb、queue_classic_adapter.rb、resque_adapter.rb、sneakers_adapter.rb 仍在仓库内以弃用状态保留,便于你对照阅读其旧实现;async_adapter.rb、inline_adapter.rb、test_adapter.rb、abstract_adapter.rb 等 Rails 自维护的适配器不受影响。
这些变更的实际影响是:如果你使用上述后端,请选择对应 gem 的适配器并关注 gem 的最低版本要求,避免在后续 Rails 版本中因内置适配器被彻底移除而中断入队。
附带修复
- 自定义序列化器加载顺序:修复了在
ActiveJob::Base尚未加载时直接调用ActiveJob::Arguments.serialize、无法使用自定义序列化器的问题(序列化器注册机制见 serializers.rb 与arguments.rb中的Serializers.serialize调用点),确保在未完整加载 Active Job 主类的前提下也能正确序列化自定义参数类型。
升级实践小结
综合上述变更,升级到 Rails 8.2 后建议按以下清单核对你的代码:
- 丢弃策略:凡
discard_on ActiveRecord::RecordNotFound用于处理「入队后记录被删」的任务,改为discard_on ActiveJob::DeserializationError::RecordNotFound;运行期RecordNotFound语义不变; - 适配器:确认正在使用的队列后端,若命中
sidekiq(已被移除)或resque(≥3.0)、delayed_job(≥4.2.0)、backburner、sneakers、queue_classic(均弃用),切换为对应 gem 提供的适配器; - 入队时机:新应用默认事务提交后入队;旧应用若需该行为,在
config/initializers/new_framework_defaults_8_2.rb中启用config.active_job.enqueue_after_transaction_commit = true; - 多步骤任务:若你正在用或计划使用
ActiveJob::Continuable编写可中断续跑的长任务,可用ActiveJob::Attributes声明跨步骤类型化状态,并确认自定义队列适配器的stopping?签名已更新为def stopping?(job = nil)以支持基于具体任务的中断决策与自定义中断原因; - 重试等待策略:
:wait现在支持 Float,并在加载期校验非法类型;需要依据错误内容动态计算延迟时,可让 wait Proc 声明第二个参数以接收 error。
以上各项能力的单元与集成测试均可分别在 attributes_test.rb、continuation_test.rb、argument_serialization_test.rb 中找到对应验证用例,可作为理解边界行为的第一手资料。