Add ActiveJob::Serializers::BigDecimalSerializer

Previously, BigDecimal was listed as not needing a serializer.  However,
when used with an adapter storing the job arguments as JSON, it would get
serialized as a simple String, resulting in deserialization also producing
a String (instead of a BigDecimal).

By using a serializer, we ensure the round trip is safe.

During upgrade deployments of applications with multiple replicas making use of
BigDecimal job arguments with a queue adapter serializing to JSON, there exists
a possible race condition, whereby a "new" replica enqueues a job with an
argument serialized using `BigDecimalSerializer`, and an "old" replica fails to
deserialize it (as it does not have `BigDecimalSerializer`).

Therefore, to ensure safe upgrades, serialization will not use
`BigDecimalSerializer` until `config.active_job.use_big_decimal_serializer` is
enabled, which can be done safely after successful deployment of Rails 7.1.

This option will be removed in Rails 7.2, when it will become the default.
This commit is contained in:
Sam Bostock 2022-07-17 13:17:30 -04:00
parent b48b196910
commit bc1f323338
No known key found for this signature in database
GPG Key ID: 453DB0A5D5DAE5D2
10 changed files with 133 additions and 4 deletions

View File

@ -1,3 +1,20 @@
* Fix BigDecimal (de)serialization for adapters using JSON.
Previously, BigDecimal was listed as not needing a serializer. However,
when used with an adapter storing the job arguments as JSON, it would get
serialized as a simple String, resulting in deserialization also producing
a String (instead of a BigDecimal).
By using a serializer, we ensure the round trip is safe.
To ensure applications using BigDecimal job arguments are not subject to
race conditions during deployment (where a replica running a version of
Rails without BigDecimalSerializer fails to deserialize an argument
serialized with it), `ActiveJob.use_big_decimal_serializer` is disabled by
default, and can be set to true in a following deployment..
*Sam Bostock*
* Preserve full-precision `enqueued_at` timestamps for serialized jobs,
allowing more accurate reporting of how long a job spent waiting in the
queue before it was performed.

View File

@ -41,4 +41,12 @@ module ActiveJob
autoload :TestCase
autoload :TestHelper
##
# :singleton-method:
# If false, Rails will preserve the legacy serialization of BigDecimal job arguments as Strings.
# If true, Rails will use the new BigDecimalSerializer to (de)serialize BigDecimal losslessly.
# This behavior will be removed in Rails 7.2.
singleton_class.attr_accessor :use_big_decimal_serializer
self.use_big_decimal_serializer = false
end

View File

@ -47,7 +47,7 @@ module ActiveJob
private
# :nodoc:
PERMITTED_TYPES = [ NilClass, String, Integer, Float, BigDecimal, TrueClass, FalseClass ]
PERMITTED_TYPES = [ NilClass, String, Integer, Float, TrueClass, FalseClass ]
# :nodoc:
GLOBALID_KEY = "_aj_globalid"
# :nodoc:
@ -93,6 +93,17 @@ module ActiveJob
when -> (arg) { arg.respond_to?(:permitted?) }
serialize_indifferent_hash(argument.to_h)
else
if BigDecimal === argument && !ActiveJob.use_big_decimal_serializer
ActiveSupport::Deprecation.warn(<<~MSG)
Primitive serialization of BigDecimal job arguments is deprecated as it may serialize via .to_s using certain queue adapters.
Enable config.active_job.use_big_decimal_serializer to use BigDecimalSerializer instead, which will be mandatory in Rails 7.2.
Note that if you application has multiple replicas, you should only enable this setting after successfully deploying your app to Rails 7.1 first.
This will ensure that during your deployment all replicas are capable of deserializing arguments serialized with BigDecimalSerializer.
MSG
return argument
end
Serializers.serialize(argument)
end
end
@ -103,6 +114,8 @@ module ActiveJob
argument
when *PERMITTED_TYPES
argument
when BigDecimal # BigDecimal may have been legacy serialized; Remove in 7.2
argument
when Array
argument.map { |arg| deserialize_argument(arg) }
when Hash

View File

@ -18,6 +18,7 @@ module ActiveJob
autoload :TimeSerializer
autoload :ModuleSerializer
autoload :RangeSerializer
autoload :BigDecimalSerializer
mattr_accessor :_additional_serializers
self._additional_serializers = Set.new
@ -63,6 +64,7 @@ module ActiveJob
TimeWithZoneSerializer,
TimeSerializer,
ModuleSerializer,
RangeSerializer
RangeSerializer,
BigDecimalSerializer
end
end

View File

@ -0,0 +1,22 @@
# frozen_string_literal: true
require "bigdecimal"
module ActiveJob
module Serializers
class BigDecimalSerializer < ObjectSerializer # :nodoc:
def serialize(big_decimal)
super("value" => big_decimal.to_s)
end
def deserialize(hash)
BigDecimal(hash["value"])
end
private
def klass
BigDecimal
end
end
end
end

View File

@ -1,10 +1,12 @@
# frozen_string_literal: true
require "bigdecimal"
require "helper"
require "active_job/arguments"
require "models/person"
require "active_support/core_ext/hash/indifferent_access"
require "jobs/kwargs_job"
require "jobs/arguments_round_trip_job"
require "support/stubs/strong_parameters"
class ArgumentSerializationTest < ActiveSupport::TestCase
@ -19,7 +21,7 @@ class ArgumentSerializationTest < ActiveSupport::TestCase
end
[ nil, 1, 1.0, 1_000_000_000_000_000_000_000,
"a", true, false, BigDecimal(5),
"a", true, false,
:a,
1.day,
Date.new(2001, 2, 3),
@ -50,6 +52,28 @@ class ArgumentSerializationTest < ActiveSupport::TestCase
end
end
test "dangerously treats BigDecimal arguments as primitives not requiring serialization by default" do
assert_deprecated(<<~MSG.chomp) do
Primitive serialization of BigDecimal job arguments is deprecated as it may serialize via .to_s using certain queue adapters.
Enable config.active_job.use_big_decimal_serializer to use BigDecimalSerializer instead, which will be mandatory in Rails 7.2.
Note that if you application has multiple replicas, you should only enable this setting after successfully deploying your app to Rails 7.1 first.
This will ensure that during your deployment all replicas are capable of deserializing arguments serialized with BigDecimalSerializer.
MSG
assert_equal(
BigDecimal(5),
*ActiveJob::Arguments.deserialize(ActiveJob::Arguments.serialize([BigDecimal(5)])),
)
end
end
test "safely serializes BigDecimal arguments if configured to use_big_decimal_serializer" do
# BigDecimal(5) example should be moved back up into array above in Rails 7.2
with_big_decimal_serializer do
assert_arguments_unchanged BigDecimal(5)
end
end
[ Object.new, Person.find("5").to_gid, Class.new ].each do |arg|
test "does not serialize #{arg.class}" do
assert_raises ActiveJob::SerializationError do
@ -208,6 +232,16 @@ class ArgumentSerializationTest < ActiveSupport::TestCase
end
def perform_round_trip(args)
ActiveJob::Arguments.deserialize(ActiveJob::Arguments.serialize(args))
ArgumentsRoundTripJob.perform_later(*args) # Actually performed inline
JobBuffer.last_value
end
def with_big_decimal_serializer(temporary = true)
original = ActiveJob.use_big_decimal_serializer
ActiveJob.use_big_decimal_serializer = temporary
yield
ensure
ActiveJob.use_big_decimal_serializer = original
end
end

View File

@ -0,0 +1,7 @@
# frozen_string_literal: true
class ArgumentsRoundTripJob < ActiveJob::Base
def perform(*arguments)
JobBuffer.add(arguments)
end
end

View File

@ -68,6 +68,7 @@ Below are the default values associated with each target version. In cases of co
- [`config.log_file_size`](#config-log-file-size): `100.megabytes`
- [`config.active_record.sqlite3_adapter_strict_strings_by_default`](#config-active-record-sqlite3-adapter-strict-strings-by-default): `false`
- [`config.active_record.allow_deprecated_singular_associations_name`](#config-active-record-allow-deprecated-singular-associations-name): `false`
- [`config.active_job.use_big_decimal_serializer`](#config-active-job-use-big-decimal-serializer): `false`
#### Default Values for Target Version 7.0
@ -2254,6 +2255,17 @@ The default value depends on the `config.load_defaults` target version:
Determines whether job context for query tags will be automatically updated via
an `around_perform`. The default value is `true`.
#### `config.active_job.use_big_decimal_serializer`
Enable the use of BigDecimalSerializer instead of legacy BigDecimal argument
serialization, which may result in the argument being lossfully converted to
a String when using certain queue adapters.
This setting is disabled by default to allow race condition free deployment
of applications with multiple replicas, in which an old replica would not
support BigDecimalSerializer..
In such environments, it should be safe to enable this setting following
successful deployment of Rails 7.1 which introduces BigDecimalSerializer.
### Configuring Action Cable
#### `config.action_cable.url`

View File

@ -278,6 +278,10 @@ module Rails
}
end
if respond_to?(:active_job)
active_job.use_big_decimal_serializer = false
end
if respond_to?(:active_support)
active_support.default_message_encryptor_serializer = :json
active_support.default_message_verifier_serializer = :json

View File

@ -47,3 +47,13 @@
# Disable deprecated singular associations names
# Rails.application.config.active_record.allow_deprecated_singular_associations_name = false
# Enable the use of BigDecimalSerializer instead of legacy BigDecimal argument
# serialization, which may result in the argument being lossfully converted to
# a String when using certain queue adapters.
# This setting is disabled by default to allow race condition free deployment
# of applications with multiple replicas, in which an old replica would not
# support BigDecimalSerializer..
# In such environments, it should be safe to enable this setting following
# successful deployment of Rails 7.1 which introduces BigDecimalSerializer.
# Rails.application.config.active_job.use_big_decimal_serializer = true