#58737·rails

Custom Active Job serializers are still ignored in 8.1.3 if a gem loads ActiveJob::Arguments early

Author: fabnCreated Sep 11, 2026Updated Sep 12, 2026

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