Store Data Not Types

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?

1 message = SomeMailer.some_event some_object
2 DelayedMessage.create! :message => message
 1 class 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
10 end

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.

1 message = SomeMailer.some_event some_object
2 DelayedMessage.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.

 1 class 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
24 end

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.

Craig Demyanovich, Software Craftsman

Craig Demyanovich is an avid hockey player, and loves visiting new places with his wife, Sandy.

Interested in 8th Light's services? Let's talk.

Contact Us
+ + +