Store Data Not Types

Store Data Not Types

Craig Demyanovich
Craig Demyanovich
February 24, 2014

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.