Performing Database Changes with Feature Flags

Releasing code behind feature flags when much of the related functionality is incomplete can sometimes require creative solutions. For example, if some new functionality requires a new column with a default value, how can we handle the scenario where the database’s default value should be different after the future flag is enabled?

Let’s look at an example where we have access tokens for an application’s API. Initially, we have simple tokens with full access to everything—read and write access. We’d like for people to be able to adjust the access levels to read only, write only, or read and write. In order to do that, we need a column for storing the token’s access level, so we’ll need a migration.

Let’s start by thinking through an enum that can work for managing token permissions. Assuming we’d want three levels of access, we’d likely use something like this:

class Token < ApplicationRecord
  # ...
  
  belongs_to :user
  
  enum access: { 
    read_only: 0,
    write_only: 1,
    read_and_write: 2
  },
  default: :read_only

  # ...
end

And we’d like pair that with a migration that adds the column with the relevant default:

class AddAccessToTokens < ActiveRecord::Migration[7.0]
  def change
    add_column :tokens, :access, :integer, default: 0, null: false
  end
end

This gives us a clear vision of the long-term plan, but with feature flags in play, we need to give it a little more thought. If we moved forward with this setup, our default token behavior will change, but we don’t want it to change until we’ve enabled our scoped_tokens flag.

Before and After Default Values

Once tokens are able to be restricted, we’d like to default to read only, but all of the existing tokens were created by people who expect those tokens to have read and write access. So adding this enum and migration would be changing the behavior before we’re ready and before the interface elements exist.

Without feature flags, we wouldn’t be able to put this code into production until everything was complete, and that’s not ideal. So we need to use :read_and_write as the default value near-term, and once we enable the flag, we want the new default to be the more restrictive :read_only.

Unfortunately, enum’s don’t support using a lambda or method for the default value. Likewise, our database can’t check the flag to change the default value once we’ve enabled it. Moreover, with the migration setting the default value to 0, all of the existing tokens would end up being restricted to read only when the column is added.

So we can’t have a dynamic default value based on the flag, and our initial default value has to match the current behavior. We need a way to set things up to continue the original behavior while the :scoped_tokens flag is disabled, but we want to build and implement it with the desired long-term behavior.

The Migration

Let’s start by looking at an updated migration that creates the column while populating the default as 2 (or :read_and_write). Then, once the column has been added and all existing tokens have their access level populated, we can update the column's default value to our new long-term behavior of using 0 (or :read_only).

class AddAccessToTokens < ActiveRecord::Migration[7.0]
  def change
    # 1. Initially, all existing token records should default
    #    to 2/read-and-write.
    add_column :tokens, :access, :integer, default: 2, null: false

    # 2. But long-term, we want to default to 0/read-only...
    change_column_default :tokens, :access, from: 2, to: 0
  end
end

This solves the problem of populating the pre-existing records, but it also means that if we release this separately from the UI, people will unwittingly be creating read-only tokens before there’s any interface to let them know the tokens are read-only. So we’re not quite done.

Ultimately, we have two choices here. We could go ahead and start building the interface for all of this, but that would significantly increase the size of the pull request. It would also introduce additional complexity by creating a larger release.

Feature Flags for the Defaults

We need to add something that will override our desired default behavior until we enable our :scoped_tokens flag. For that, we can use an after_initialize callback. While I don't regularly use callbacks, it's a perfect solution for short-lived behavior tied to feature flags. With callbacks, the feature flag behavior is compartmentalized and can cleanly be deleted when we’re ready.

You may also notice the comment begins with # flag:scoped_tokens. That simply provides a predictable hook for global searches when it comes time to remove a flag once a feature has been running successfully in production.

class Token
  ...

  # flag:scoped_tokens - Remove this callback/method once enabled.
  after_initialize :apply_access_default_from_flag, if: :new_record?

  ...

  def scoping_enabled?
    Flipper.enabled?(:scoped_tokens, user)
  end

  private

  def apply_access_default_from_flag
    # Long-term, the default will be read-only, but until enabled, 
    #   we need it to default to read-write until we build the UI.
    self.access = scoping_enabled? ? :read_only : :read_and_write
  end
end

This way, we're able to create our data model based on our long-term goals without letting the future behavior go into action before we've finished building the other elements that we'll need for read-only access via tokens.

Testing All States

With our defaults set up and overridden based on the feature flag, we can be confident that the default behavior won’t change until the feature is enabled. And once it is enabled, we can directly delete the callback and method safely with a high-level of confidence that it will behave as expected.

But just to be safe, let’s make sure we’ve covered all of our bases with some quick tests. We’ll test the behavior with the flag enabled and disabled. Then we’ll add a test that explicitly removes the callback so that it tests the behavior we expect after we clean up and fully remove the flag.

require 'test_helper'

class TokenTest < ActiveSupport::TestCase
  test "initializes as read-write with scoped tokens disabled" do
    Flipper.disable(:scoped_tokens)
    token = Token.new
    assert_predicate token, :read_and_write?
    refute_predicate token, :read_only?
  end

  test "initializes as read-only with scoped tokens enabled" do
    Flipper.enable(:scoped_tokens)
    token = Token.new
    refute_predicate token, :read_and_write?
    assert_predicate token, :read_only?
  end

  test "initializes as read-only with access callback removed" do
	# flag:scoped_tokens - Update test by removing `skip_callback`
    Token.skip_callback(
      :initialize, 
      :after, 
      :apply_access_default_from_flag
    )

token = Token.new
    refute_predicate token, :read_and_write?
    assert_predicate token, :read_only?

	# flag:scoped_tokens - Update test by removing `set_callback`
	Token.set_callback(
      :initialize,
      :after,
      :apply_access_default_from_flag
    )
  end
end 

With all of that in place, we’ll be able to prepare our Token model by adding the column and preparing the correct default values. Until we start filling in the rest of the code like the UI or implementing the restrictions, we can be confident our new logic works as expected for all of the scenarios it will encounter.

The end result is a highly-focused and compact pull request focused entirely on the model. As a result, it will be easier to review and approve since it's narrowly focused on data. Then we’ll be able to deploy the changes and trust that it will work as expected while we build the remaining functionality.