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?


message = SomeMailer.some_event some_object
DelayedMessage.create! :message => message

class DelayedMessage < ActiveRecord::Base
		serialize :message, TMail::Mail

		def deliver
				if message
						ActionMailer::Base.deliver message
						destroy
				end
		end
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.


message = SomeMailer.some_event(some_object)
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.

class DelayedMessage < ActiveRecord::Base
		def message
				m = read_attribute(:message).to_s
				return nil if m.blank?
				if in_earlier_serialized_format? m
						m = YAML.load m
				end
				Mail.read_from_string m.to_s
		end

		def deliver
				if m = message
						prepare_for_delivery m
						m.deliver
						destroy
				end
		end

		private

		def in_earlier_serialized_format?(m)
				m.start_with? '--- !ruby/object:TMail::Mail'
		end
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.