I successfully upgraded a Rails application from version 2.3 to 3.2. Or so I thought.
"The Best Laid Plans..."
Though I read lots of blog posts; though I watched the excellent videos from RailsCasts; though I used the rails_upgrade
plugin; though I made and worked through my own checklist; though all the tests passed; though manual testing found no issues; it wasn't long after deploying to production that I received the first email from our exception notifier.
In brief, there was a problem sending email caused by an inability to deserialize DelayedMessage#message
for old records.
Here's the simplified code for DelayedMessage
and its use. Can you spot the problem?
1message = SomeMailer.some_event some_object
2DelayedMessage.create! :message => message
1class DelayedMessage < ActiveRecord::Base
2 serialize :message, TMail::Mail
3
4 def deliver
5 if message
6 ActionMailer::Base.deliver message
7 destroy
8 end
9 end
10end
The Problem
Email is defined by the "Internet Message Format" specified in RFC 2282. But, the code above is not storing the email that way. It's storing a TMail::Mail
, the type returned from the ActionMailer
APIs in Rails 2. Why is this a problem? Rails 3 changed its implementation of email. It moved from the tmail
gem to the mail
gem. Consequently, the ActionMailer
APIs now return a Mail::Message
instead of a TMail::Mail
.
How, then, can we change the system to (1) work with existing DelayedMessage
records and (2) be more resilient to any future changes in the email implementation in Rails?
Step 1: Store Data Instead of a Type
Instead of storing an instance of the message, a TMail::Mail
, we store the string representation of the message.
1message = SomeMailer.some_event some_object
2DelayedMessage.create! :message => message.to_s
We have two things working in our favor here. First, since DelayedMessage#message
is a text
column in our database, we don't have to change it to be able to store the message as a long string. Second, Mail::Message#to_s
returns the message as a string that conforms to RFC 2282.
Step 2: Read Old and New Messages
Now that we store the message as a string, let's retrieve it as one, too. We take control of retrieval by providing our own implementation of DelayedMessage#message
.
1class DelayedMessage < ActiveRecord::Base
2 def message
3 m = read_attribute(:message).to_s
4 return nil if m.blank?
5 if in_earlier_serialized_format? m
6 m = YAML.load m
7 end
8 Mail.read_from_string m.to_s
9 end
10
11 def deliver
12 if m = message
13 prepare_for_delivery m
14 m.deliver
15 destroy
16 end
17 end
18
19 private
20
21 def in_earlier_serialized_format?(m)
22 m.start_with? '--- !ruby/object:TMail::Mail'
23 end
24end
By always converting the stored message to a string during retrieval, we can determine whether the message is in the old format, a serialized TMail::Mail
. Either way, we can call #to_s
on it to ensure we're working with a string representation that conforms to RFC 2282, since we either stored it that way (our earlier change) or obtain that result from TMail::Mail#to_s
. Then, all we have to do is use Mail.read_from_string
to parse the string into a Mail::Message
instance, which we can then use with Rails 3.
The Only Constant in Life is Change
One of the many changes introduced in Rails 3 was the API for sending email. Though the API changed, the observable behavior, that an email was sent, remained the same. Rails 2 and 3 use an email implementation that conforms to the "Internet Message Format" in RFC 2282. Yet, the application managed to subvert that conformance by storing a type. While we can overcome such problems, it's best to avoid them by storing data instead.