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.