Fix Sneaky ArgumentErrors When Upgrading Ruby

Fix Sneaky ArgumentErrors When Upgrading Ruby

Upgrading from Ruby 2 to Ruby 3 can be a challenging task, especially when your Rails application relies on ActiveJob with Sidekiq. In the process, you may encounter cryptic ArgumentError exceptions that make the upgrade seem daunting. By following along you’ll be well-equipped to avoid some of the hurdles that come your way.

TLDR;

  • Fix unknown keyword by making sure you correctly separate positional and keyword args.
  • Always pass hashes with only string or only symbolic keys, avoid mixing string and symbolic keys in hash arguments.

Issue: unknown keyword: :id

We recently undertook the task of upgrading a Rails application that used ActiveJob with Sidekiq as the backend adapter. Our goal was to transition the application from Ruby 2 to Ruby 3.

We are aware that Ruby 3 introduces a requirement to explicitly separate positional arguments from keyword arguments. This stands in contrast to Ruby 2, where we had the flexibility to pass a hash as the last argument, and Ruby would interpret it as keyword arguments. Read more about this change opens a new window .

Ruby 2 interprets the last argument hash as keyword arguments. Ruby 2 hash passed as keyword argument

Ruby 3 interprets the last argument hash as a positional argument. Ruby 3 hash passed as positional argument

This change in Ruby 3 does not require that all last argument hashes be changed into keyword arguments. It is still valid to pass a hash as a positional argument. However, this flexibility can pose a challenge when identifying which arguments need to be updated.

Maintaining comprehensive test coverage with high-quality tests proves invaluable in finding the arguments that require updating. Additionally, utilizing a bug tracking system that promptly alerts you to any mismatches between positional and keyword arguments is crucial.

The application we worked on had notably low test coverage. To compensate, we conducted thorough keyword searches across the application‘s files, targeting potentially problematic areas that may not have been captured by our tests.

To mitigate the risks associated with the low test coverage, we implemented several measures. One such measure was proactively updating keyword arguments in Ruby 2. We introduced the (**) double splat operator to clearly separate keyword arguments, ensuring a smoother transition when deploying in Ruby 3.

During our QA process, while still on Ruby 2.7.6, we encountered an exception that was captured by our bug tracking system. The exception message provided valuable information, revealing the following details:

ArgumentError

unknown keyword: :id

We followed the stack trace to pinpoint the exact location where the exception was raised. This enabled us to identify the specific section of the code. A simplified example of the code snippet is as follows:

# inside a sidekiq job file

def perform(params)
  args = ["some-value", params]
  Service.run(*args)
end
# inside service.rb

def self.run(*args, **kwargs)
  new.run(*args, **kwargs)
end

def run(uuid, params)
  # do work with the uuid and params
end

Notice that we have a very simple Sidekiq job that calls Service‘ class-level run method. The class method accepts any positional or keyword arguments and delegates them to the instance-level run method.

The error message appeared cryptic since we were not calling run with an :id keyword argument. In the example above, the run method expects two positional arguments named uuid and params.

The params hash included an attribute named id. This led us to question: “Why would a key within a hash cause this error?” This was particularly intriguing as we were passing the hash as a positional argument rather than a keyword argument.

Serialize/Deserialize: ActiveJob vs Sidekiq

To unravel this issue, it is beneficial to gain a deeper understanding of the internal mechanics of ActiveJob and Sidekiq.

Sidekiq serializes job arguments into JSON and then deserializes the JSON string when the job is ready for execution. One notable aspect is that during the serialization/deserialization process, symbolic hash keys are lost.

x = { id: "asdf"}
y = JSON.dump(x) # y == "{ 'id' : 'asdf' }"
JSON.parse(y) #  {"id"=>"asdf"}

ActiveJob (AJ) performs more advanced serialization and deserialization. One of the key differences is that AJ can serialize a hash with a mix of string and symbol keys without losing track of the different key types. This means that when we deserialize to execute the job, we still have these symbol and string keys in our hash.

If AJ serialized { id: "asdf" } and you then looked at the serialized data you would see this:

"{ 'id': 'asdf', '_aj_symbol_keys' => ['id'] }"

To keep track of keys that should be deserialized as symbolic keys, ActiveJob introduced the _aj_symbol_keys attribute.

Hashes with string and symbolic keys

Now, if we revisit the previously mentioned code snippet, provided below, we would be wise to examine the params hash more closely. Upon investigation, we discovered that the params hash did indeed include a symbolic key. As you might have guessed, the symbolic key in question was :id.

# inside  sidekiq job file

# params == { "message" => "asdf", :id => 1}
def perform(params)
  args = ["some-value", params]
  Service.run(*args)
end

Since we were passing a hash with a combination of string and symbol keys, Ruby 2 interpreted the symbolic keys as keyword arguments. Ruby 2 hash with symbolic keys used as keyword arguments

You can verify this behavior by following these steps:

  • ensure you have Ruby 2.7.6 installed,
  • then open an irb session,
  • define 3 methods by executing the following in irb.
def test(id, params = {}); pp({ id: id, param: params}); end
def args_and_kwargs(*args, **kwargs); test(*args, **kwargs); end
def args_only(*args); test(*args); end

Now, let’s define an argument that includes a hash with only string keys. Once we have the argument defined, we can confidently call the previously defined methods without encountering any issues.

args = ["asdf", {"message"=>"asdf", "id"=>"asdf"}]
args_only(*args)
args_and_kwargs(*args)
# => {:id=>"asdf", :param=>{"message"=>"asdf", "id"=>"asdf"}}

The code above runs, as we anticipated, without raising any exceptions. However, let’s observe what happens when we update one of the keys to be a symbolic key.

["asdf", {"message"=>"asdf", :id=>"asdf"}]
args_only(*args)
args_and_kwargs(*args)
# => in `test': unknown keyword: :id (ArgumentError)

Now we see the same error caught by our bug tracker.

The Solution

By manually updating the hash construction, you can ensure that all keys are strings from the beginning. Alternatively, you can use the stringify_keys method, which is available in Rails and can be called on the hash to convert any symbol keys to string keys.

In this case the solution was to make sure that we pass only string keyed hashes. You can manually update how the hash is constructed or you can call stringify_keys opens a new window on the hash before you pass it around.

What did we learn?

Delegating arguments in Ruby can be done in various ways, but it’s important to note that not all methods are compatible between versions 3 and 2. Ruby 3 requires explicit separation of positional and keyword arguments, which may impact certain argument delegation techniques used in Ruby 2.

When upgrading from Ruby 2 to Ruby 3, it is essential to review and update argument delegation methods to ensure compatibility.

Next, it is advisable to maintain consistency in the types of hash keys whenever possible. This simple practice can save you hours of debugging code, as you may encounter situations where the symbol part of a hash inadvertently becomes a keyword argument when used in a function call.

Background jobs can become more complex when using keyword arguments. In particular, Sidekiq 7 provides options opens a new window to warn or raise exceptions when passing keyword arguments or symbols to a job.

It is recommended to follow best practices by keeping job parameters small and simple.

Thank you for reading this post! I hope you found the information helpful in your Ruby or Rails upgrade project. If you have any upgrade projects and need assistance or advice, feel free to reach out opens a new window . We are here to help and provide guidance to make your upgrade process pain free.

Stay tuned for more helpful content, and don’t hesitate to ask if you have any further questions or requests.

Further reading

Get the book