Custom Active Job serializers are still ignored in 8.1.3 if a gem loads ActiveJob::Arguments early
This is a follow-up to #56090, which was fixed by #56093 in 8.1.2. That fix covers the case where ActiveJob::Arguments has not been loaded yet. The opposite case is still broken: if anything loads ActiveJob::Arguments before the application's initializers run, a serializer registered the way the guide prescribes is never registered at all.
It fails quietly. config.active_job.custom_serializers ends up holding the serializer, so the configuration looks correct and boot produces no warning. The error only appears later, the first time a job is enqueued with such an argument.
Steps to reproduce
Run twice, with EAGER=0 and EAGER=1:
# frozen_string_literal: true
require "bundler/inline"
gemfile(true) do
source "https://rubygems.org"
gem "rails", "8.1.3.1"
end
require "tmpdir"
require "active_job/railtie"
require "minitest/autorun"
# Any gem that requires this at load time defeats the registration below.
# `noticed` does it on the first line of lib/noticed.rb, which runs during
# Bundler.require — before any application initializer.
require "active_job/arguments" if ENV["EAGER"] == "1"
Money = Struct.new(:cents)
class MoneySerializer < ActiveJob::Serializers::ObjectSerializer
def klass = Money
def serialize(money) = super("cents" => money.cents)
def deserialize(hash) = Money.new(hash["cents"])
end
class MoneyJob < ActiveJob::Base
def perform(money) = money
end
root = Dir.mktmpdir
Dir.mkdir("#{root}/config")
Dir.mkdir("#{root}/config/initializers")
# Exactly what the guide prescribes.
File.write("#{root}/config/initializers/custom_serializers.rb",
"Rails.application.config.active_job.custom_serializers << MoneySerializer\n")
class TestApp < Rails::Application
config.load_defaults Rails::VERSION::STRING.to_f
config.eager_load = false
config.secret_key_base = "secret_key_base"
config.active_job.queue_adapter = :test
end
TestApp.config.root = root
Rails.application.initialize!
class CustomSerializerTest < ActiveSupport::TestCase
def test_the_initializer_did_register_the_serializer_in_the_config
assert_equal [MoneySerializer], Rails.application.config.active_job.custom_serializers
end
def test_enqueueing_a_job_with_a_custom_serialized_argument
MoneyJob.perform_later(Money.new(100))
end
def test_arguments_serialize
assert_equal [{ "_aj_serialized" => "MoneySerializer", "cents" => 100 }],
ActiveJob::Arguments.serialize([Money.new(100)])
end
end
Expected behavior
All three tests pass, as they do on 8.0.
Actual behavior
| Rails | EAGER=0 |
EAGER=1 |
|---|---|---|
| 8.0.5.1 | 3 runs, 0 errors | 3 runs, 0 errors |
| 8.1.3.1 | 3 runs, 0 errors | 3 runs, 1 assertion, 2 errors |
The configuration test passes. Both serialization paths fail:
CustomSerializerTest#test_enqueueing_a_job_with_a_custom_serialized_argument:
ActiveJob::SerializationError: Unsupported argument type: Money
activejob-8.1.3.1/lib/active_job/serializers.rb:33:in `serialize'
activejob-8.1.3.1/lib/active_job/arguments.rb:74:in `serialize_argument'
activejob-8.1.3.1/lib/active_job/core.rb:201:in `serialize_arguments'
CustomSerializerTest#test_arguments_serialize:
ActiveJob::SerializationError: Unsupported argument type: Money
Note this is the ordinary perform_later path, not the internal ActiveJob::Serializers.serialize — which is what the earlier report on #56090 was set aside for.
Why the 8.1.2 fix doesn't cover this
run_load_hooks(:active_job_arguments, Arguments) is the last line of arguments.rb, so the hook fires when that file loads. The railtie registers its block inside initializer "active_job.custom_serializers":
initializer "active_job.custom_serializers" do |app|
ActiveSupport.on_load(:active_job_arguments) do
ActiveJob::Serializers.add_serializers app.config.active_job.custom_serializers
end
end
If the file is already loaded at that point, on_load runs the block immediately — while railtie initializers are still running, before :load_config_initializers. It reads an empty array, and nothing reads the configuration again afterwards. #56093 makes sure the hook eventually fires when it hasn't yet; it doesn't help when it has already fired too early.
Whether this happens is decided by a dependency's load order, so an application can't reliably avoid it. autoload_once_paths, which the guide recommends alongside, doesn't help either: it makes the constant resolvable inside the initializer, but doesn't change when the configuration is read.
The move to a load hook came in b7cd3018 (#55600), whose stated goal was to defer registration so boot wouldn't eager-load application code in development and test. The failure mode is the hook running earlier than intended rather than later.
Related
- #56090 — same symptom, fixed by #56093 for the not-yet-loaded case
- #56090 (comment) — an independent report of this variant still failing on 8.1.2
- #55998 — earlier duplicate, identifies b7cd3018 / #55600
System configuration
Rails version: 8.1.3.1 (8.0.5.1 unaffected) Ruby version: 3.3.9
Source: rails/rails