Migrating Session Flash from Rails 3.0.x to 3.2.x

When upgrading a high-traffic Rails application from 3.0.x to 3.2.x, existing sessions with Flash data will raise dump format error during the session unmarshalling step of the users next request. For an ostensibly minor patch (see semver), breaking user sessions is an unexpected surprise and a real bummer for your users.

The Rails breaking change to FlashHash was to stop inheriting from Hash:

# Rails 3.0 - See: http://git.io/X8WqIw
class FlashHash < Hash

# Rails 3.2 - See: http://git.io/jJ_Qhw
class FlashHash

Dealing with serialization errors can be notoriously difficult, even more so because these were occurring deep in the framework. So, we monkey patched ActiveRecord::SessionStore::Session to remove the offending Flash data. The possible loss was a notification displayed on the page, the benefit was maintaining user sessions across the upgrade deployment.

If we catch the dump format error, assume it's the Flash and delete it, the remainder of the session may be unmarshalled and accessed.

ActiveRecord::SessionStore::Session.class_eval do

 def self.unmarshal(data)
   return unless data

   marshalled = nil
   begin
     decoded = ::Base64.decode64 data
     marshalled = Marshal.load decoded
   rescue ArgumentError => ae
     if ae.message =~ /dump format error/
       with_alternate_flash_klass do
         marshalled = Marshal.load decoded
         marshalled.delete('flash')
       end
     else
       raise ae
     end
   end

   marshalled
 end

 ...
end

We start by monkey patching the ActiveRecord::SessionStore::Session#unmarshal method to rescue ArgumentError. see original method

If the ArgumentError message is dump format error, we use a back-ported FlashHash implementation and attempt to decode again. This time we delete the 'flash' data and return the unmarshalled session. Going forward, the session should be valid because new Flash data will be from the current FlashHash object.

In the monkey patched ActiveRecord::SessionStore::Session#unmarshal above, you'll notice the with_alternate_flash_klass block for the second attempt to unmarshal. This method takes the block to execute and handles the redefining of the FlashHash constant for you. It conveniently restores the FlashHash after the unmarshalling.

First, we create helper method to dynamically create the alternate FlashHash class:

def self.alternate_flash_klass
  if ActionDispatch::Flash::FlashHash.ancestors.include? Hash
    Class.new() do #rails3.2 FlashHash
      def initialize
        @used    = Set.new
        @closed  = false
        @flashes = {}
        @now     = nil
      end
    end
  else
    Class.new(Hash) do #rails3.0 FlashHash
      def initialize
        super
        @used = Set.new
      end
    end
  end
end

Using Class.new(), we have the option of passing in a parent, which we do for the rails 3.0 version.

Next, we need a method to swap the FlashHash and execute a block:

def self.with_alternate_flash_klass
  temporary_flash_hash_klass = alternate_flash_klass

  original_flash_hash_klass = ActionDispatch::Flash::FlashHash
  ActionDispatch::Flash.send :remove_const, :FlashHash
  ActionDispatch::Flash.const_set 'FlashHash', temporary_flash_hash_klass

  yield

  ActionDispatch::Flash.send :remove_const, :FlashHash
  ActionDispatch::Flash.const_set 'FlashHash', original_flash_hash_klass
end

This saves the current FlashHash in original_flash_hash_klass, redefines the constant. When the block is yielded, the unmarshal is attempted with the back-ported FlashHash. Then we restore the original FlashHash.

Astute readers, will notice alternate_flash_klass will provide a back-port or forward-port implementation of FlashHash. This allows us to deploy Rails 3.2 and rollback to Rails 3.0. If you deployed Rails 3.2, allowed some sessions to be persisted, then rolled back, those sessions would be corrupt. This implementation supports going forward and backward with only a loss of Flash notifications.

config/initializers/rails32_session_upgrade_patches.rb

You can add this code to an initialization file. You may only need it for 30 days or until your sessions expire. We have a warn so your reminded this monkey patch is still present in your code.

# ActionDispatch::Flash::FlashHash changed between 3.0 and 3.2
# Rails 3.0.x FlashHash inherits from Hash, this was removed in Rails 3.2.x
# if a session was created in 3.0.x, after deploy we would receive
# 'dump format error' when trying to deserialize the session
#
# this monkey patch attempts to unmarshal with an alternate version
# of FlashHash and deletes it if successful
#
warn "[TEMPORARY] Loading session monkey patches for Rails 3.0.x => 3.2.x upgrade"

ActiveRecord::SessionStore::Session.class_eval do

  def self.unmarshal(data)
    return unless data

    marshalled = nil
    begin
      decoded = ::Base64.decode64 data
      marshalled = Marshal.load decoded
    rescue ArgumentError => ae
      if ae.message =~ /dump format error/
        with_alternate_flash_klass do
          marshalled = Marshal.load decoded
          marshalled.delete('flash')
        end
      else
        raise ae
      end
    end

    marshalled
  end

  def self.alternate_flash_klass
    if ActionDispatch::Flash::FlashHash.ancestors.include? Hash
      Class.new() do #rails3.2 FlashHash
        def initialize
          @used    = Set.new
          @closed  = false
          @flashes = {}
          @now     = nil
        end
      end
    else
      Class.new(Hash) do #rails3.0 FlashHash
        def initialize
          super
          @used = Set.new
        end
      end
    end
  end

  def self.with_alternate_flash_klass
    temporary_flash_hash_klass = alternate_flash_klass

    original_flash_hash_klass = ActionDispatch::Flash::FlashHash
    ActionDispatch::Flash.send :remove_const, :FlashHash
    ActionDispatch::Flash.const_set 'FlashHash', temporary_flash_hash_klass

    yield

    ActionDispatch::Flash.send :remove_const, :FlashHash
    ActionDispatch::Flash.const_set 'FlashHash', original_flash_hash_klass
  end
end

Bonus! Tests

NOTE you will need to copy session from your application for these tests to pass. The session data below is invalid.

require 'test_helper'

class Rails32SessionUpgradePatchesTest < ActiveSupport::TestCase

  test 'remove flash from rails 3.0.x sessions in rails 3.2.x' do
    rails30_session = '7CUkiDnByb2R1Y3RpZAY6BkVGSSIKMDExMDQGOwBUSSIMbGFiX3VybAY7
    AEYiNGh0dHA6Ly93d3cuY3VzdG6taW5rLmNvbS9sYWI/Y2lkPWNobTAtMDAx
    akxVVlYyOWVxVHRPQk9ES1NCNFliVjVWUT0GOwBGSSIKZmxhc2gGOwBGSUM6
    JUFjdGlvbkRpc3BhdGNoOjpGbGFzaDo6Rmxhc2hIYXNoewY6CmVycm9yewc6
    IGluZm9ybWF0aW9uLgY7AEY6DG1lc3NhZ2VJIiZQbGVhc2UgZmlsbCBpbiBh
    IHJlY2lwaWVudCBiZWxvdy4GOwBGBjoKQHVzZWRvOghTZXQGOgpAaGFzaHsG
    OwdU'

    session = ActiveRecord::SessionStore::Session.unmarshal rails30_session
    assert_nil session['flash']
  end if Rails.version =~ /^3\.2/

  test 'remove flash from rails 3.2.x sessions in rails 3.0.x' do
    rails32_session = '7BkkiCmZsYXNoBjoGRUZvOiVBY3Rpb25EaXNwYXRjaDo6Rmxhc2g6OkZs
    YXNoSGFzaAk6CkB1c2VkbzoIU2V0BjoKQGhhc2h7BjoJd2FyblQ6DEBjbG9z
    ZWRGOg1AZmxhc2hlc3sGOwp7BjoMbWVzc2FnZUkiAcJQbGVhc2Ugbm90ZSB0
    dHdvcmsgYmVmb3JlIHByb2NlZWRpbmcgd2l0aCBwcm9kdWN0aW9uLiBQbGVh
    bWVudCB3aXRoIGEgbGluayB0byB5b3VyIGZpbmFsIHBpY3R1cmUgcHJvb2Zz
    LgY7AEY6CUBub3dvOiRBY3Rpb25EaXNwYXRjaDo6Rmxhc2g6OkZsYXNoTm93
    BjoLQGZsYXNoQAc='

    session = ActiveRecord::SessionStore::Session.unmarshal rails32_session
    assert_nil session['flash']
  end if Rails.version =~ /^3\.0/
end

Pretty?

No, it's not pretty. But it will help you overcome an unexpected dump format error when deploying a Rails 3.2 upgrade.

by Chris Mar