DEV Community

Cover image for Data Migration Strategies in Ruby on Rails: The Right Way to Manage Missing Data
Vlad Hilko
Vlad Hilko

Posted on • Edited on

Data Migration Strategies in Ruby on Rails: The Right Way to Manage Missing Data

Overview

In this article, we're going to discuss the possible strategies for migrating, generating, and backfilling data in a Rails application. We'll implement them, improve them, consider their pros and cons, and discuss which ones are better to use in different scenarios. By the end of the article, we will have a full picture of the different ways to solve data migration problems.

Introduction

In simple terms, data migration is the process of adding, updating, or transferring some data inside your application. The most popular cases for data migration are as follows:

  • Backfilling column data
  • Moving column data from one table to another
  • Generating new database records
  • Updating corrupted or invalid data with correct values
  • Removing unused data

We'll consider 3 different ways to do it:

  • Direct Data Manipulation
  • Rake Task
  • Data Migration Gem

Direct Data Manipulation

The first option is the simplest one: we'll just add missing data via rails c or through a direct database connection in production.

Advantages:

  • Easy
  • No need to implement anything new
  • It's fast because data migration can be done in minutes.

Problems:

  • Too risky; changes may not end up as intended
  • Possible access and security problems
  • There are no tests and code reviews, so we can't be sure of the quality
  • Lack of control; you don't know who ran the migration or why they ran it.

Rake Task

The second option is the rake task. In this chapter, we will try to understand how to properly add rake tasks, ensure that they work correctly, learn their pros and cons, and explore how they can be used for data migration. We will start by adding the simplest rake task, and then we will proceed to improve its structure, cover the logic with tests, and consider using best practices for writing data migration using rake tasks.

Let's imagine that we have an Animal model with the following fields:

  • id
  • kind
  • status
  • created_at
  • updated_at

And we need to change the status value from nil to reserved for all animals that we created before today. How can we do it? Let's start by adding a simple rake task template.

# frozen_string_literal: true

# lib/tasks/animals/backfill_statuses.rake

namespace :animals do
  desc "Update animal status to 'reserved' for animals created before today"
  task backfill_statuses: [:environment] do
    puts 'Updating animal status...'
  end
end
Enter fullscreen mode Exit fullscreen mode

And check that it works:

rake animals:backfill_statuses
# => Updating animal status...
Enter fullscreen mode Exit fullscreen mode

The task has been executed and works as expected. Now, let's add the actual code with the database update. It will look like the following:

# frozen_string_literal: true

# lib/tasks/animals/backfill_statuses.rake

namespace :animals do
  desc "Update animal status to 'reserved' for animals created before today"
  task backfill_statuses: [:environment] do
    Animal.where(status: nil).where('created_at < ?', Time.zone.today).update_all(status: 'reserved')
  end
end
Enter fullscreen mode Exit fullscreen mode

Now, let's check if it works:

rake animals:backfill_statuses
Enter fullscreen mode Exit fullscreen mode

The rake task has been executed, and the database values are updated accordingly. That's it. Our main scenario works as expected, but there's still some room for improvements. Let's take a look at what we can do to make our task more reliable.

Improvements

There are 5 areas that we can potentially improve:

  • Display results in the console for visibility
  • Ensure data consistency with transactions
  • Optimize DB requests
  • Isolate the rake task code
  • Add tests

Display results in the console for visibility

As you may have noticed, the rake task above hasn't shown any output. It can be a real problem because you don't know whether it was run successfully or not, and you will spend much time trying to check it by yourself on production data. Let's fix this problem:

# frozen_string_literal: true

# lib/tasks/animals/backfill_statuses.rake

namespace :animals do
  desc "Update animal status to 'reserved' for animals created before today"
  task backfill_statuses: [:environment] do
    puts "Before running the rake task, there were #{Animal.where(status: 'reserved').count} animals in the 'reserved' state."

    Animal.where(status: nil).where('created_at < ?', Time.zone.today).update_all(status: 'reserved')

    puts "After running the rake task, there are now #{Animal.where(status: 'reserved').count} animals in the 'reserved' state."
  end
end
Enter fullscreen mode Exit fullscreen mode

Now, let's run the rake task:

rake animals:backfill_statuses

# => Before running the rake task, there were 0 animals in the 'reserved' state.
# => After running the rake task, there are now 101 animals in the 'reserved' state.
Enter fullscreen mode Exit fullscreen mode

With the updated code, we display the number of animals in the 'reserved' state both before and after running the rake task, providing better visibility and ensuring that the task is executed successfully.

Ensure data consistency with transactions

What happens if some unexpected errors appear in the middle of data migration? Right now, we don't handle it. Even if it's not critical for the example that we've provided, in general, we should not forget to wrap such kind of data manipulation into a transaction to keep a consistent data state.

# frozen_string_literal: true

# lib/tasks/animals/backfill_statuses.rake

namespace :animals do
  desc "Update animal status to 'reserved' for animals created before today"
  task backfill_statuses: [:environment] do
    ActiveRecord::Base.transaction do
      Animal.where(status: nil).where('created_at < ?', Time.zone.today).update_all(status: 'reserved')
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

By adding the ActiveRecord::Base.transaction block, we ensure that all the updates are executed as a single atomic operation. If any error occurs during the data migration, the transaction will be rolled back, and the data will remain unchanged, maintaining data consistency and integrity.

P.S. If you have very large tables, you need to keep in mind that simply UPDATE all rows would cause a lock against writes on the whole table. That is why updates must be done in batches. The migration must not run within a transaction for the same reason.

Optimize DB requests

We have already handled this problem in our rake task, but it's important to mention that we should use the optimal database solution if possible. For example, someone could write our task like this:

# frozen_string_literal: true

# lib/tasks/animals/backfill_statuses.rake

namespace :animals do
  desc "Update animal status to 'reserved' for animals created before today"
  task backfill_statuses: [:environment] do
    Animal.where(status: nil).where('created_at < ?', Time.zone.today).each do |animal|
      animal.update(status: 'reserved')
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

The following code will trigger an SQL update request for every animal from the list, making it non-optimal:

D, [2023-07-21T09:50:58.346040 #67787] DEBUG -- :   Animal Load (1.6ms)  SELECT `animals`.* FROM `animals` WHERE `animals`.`status` IS NULL AND (created_at < '2023-07-21')
D, [2023-07-21T09:50:58.346735 #67787] DEBUG -- :   ↳ lib/tasks/animals/backfill_statuses.rake:10:in `block (2 levels) in <main>'
D, [2023-07-21T09:50:58.371908 #67787] DEBUG -- :   TRANSACTION (2.2ms)  BEGIN
D, [2023-07-21T09:50:58.372737 #67787] DEBUG -- :   ↳ lib/tasks/animals/backfill_statuses.rake:11:in `block (3 levels) in <main>'
D, [2023-07-21T09:50:58.375091 #67787] DEBUG -- :   Animal Update (2.2ms)  UPDATE `animals` SET `animals`.`status` = 'reserved', `animals`.`updated_at` = '2023-07-21 07:50:58.368697' WHERE `animals`.`id` = 1
D, [2023-07-21T09:50:58.375713 #67787] DEBUG -- :   ↳ lib/tasks/animals/backfill_statuses.rake:11:in `block (3 levels) in <main>'
D, [2023-07-21T09:50:58.381169 #67787] DEBUG -- :   TRANSACTION (5.0ms)  COMMIT
D, [2023-07-21T09:50:58.381524 #67787] DEBUG -- :   ↳ lib/tasks/animals/backfill_statuses.rake:11:in `block (3 levels) in <main>'
D, [2023-07-21T09:50:58.383624 #67787] DEBUG -- :   TRANSACTION (1.3ms)  BEGIN
D, [2023-07-21T09:50:58.384250 #67787] DEBUG -- :   ↳ lib/tasks/animals/backfill_statuses.rake:11:in `block (3 levels) in <main>'
D, [2023-07-21T09:50:58.385792 #67787] DEBUG -- :   Animal Update (1.4ms)  UPDATE `animals` SET `animals`.`status` = 'reserved', `animals`.`updated_at` = '2023-07-21 07:50:58.381901' WHERE `animals`.`id` = 2

...
Enter fullscreen mode Exit fullscreen mode

That's why it always makes sense to try to find a way to do it in a single DB operation, as we did with the following code:

# frozen_string_literal: true

# lib/tasks/animals/backfill_statuses.rake

namespace :animals do
  desc "Update animal status to 'reserved' for animals created before today"
  task backfill_statuses: [:environment] do
    Animal.where(status: nil).where('created_at < ?', Time.zone.today).update_all(status: 'reserved')
  end
end
Enter fullscreen mode Exit fullscreen mode

This code will trigger only one DB request:

Animal Update All (6.8ms)  UPDATE `animals` SET `animals`.`status` = 'reserved' WHERE `animals`.`status` IS NULL AND (created_at < '2023-07-21')
Enter fullscreen mode Exit fullscreen mode

If there's no way to update something in a single DB request, at least you should consider using batches as a good practice:

# frozen_string_literal: true

# lib/tasks/animals/backfill_statuses.rake

namespace :animals do
  desc "Update animal status to 'reserved' for animals created before today"
  task backfill_statuses: [:environment] do
    Animal.where(status: nil).where('created_at < ?', Time.zone.today).find_each do |animal|
      animal.update(status: 'reserved')
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

P.S. To see SQL logs from the rake task, you can add the following code inside:

# frozen_string_literal: true

# lib/tasks/animals/backfill_statuses.rake

namespace :animals do
  desc "Update animal status to 'reserved' for animals created before today"
  task backfill_statuses: [:environment] do
    ActiveRecord::Base.logger = Logger.new(STDOUT)

    # ...
  end
end
Enter fullscreen mode Exit fullscreen mode

Isolate the rake task code

One not so obvious problem that can occur when you have many rake tasks is a lack of encapsulation. Let's take a look at the following two rake tasks and try to guess what can be wrong here:

  • rake animals:task1
# frozen_string_literal: true

# lib/tasks/animals/task1.rake

namespace :animals do
  task task1: [:environment] do
    puts message
  end

  def message
    'Hello world from Task 1!'
  end
end
Enter fullscreen mode Exit fullscreen mode
  • rake animals:task2
# frozen_string_literal: true

# lib/tasks/animals/task2.rake

namespace :animals do
  task task2: [:environment] do
    puts message
  end

  def message
    'Hello world from Task 2!'
  end
end
Enter fullscreen mode Exit fullscreen mode

Now let's run both of them:

rake animals:task1
# => Hello world from Task 2!
rake animals:task2
# => Hello world from Task 2!
Enter fullscreen mode Exit fullscreen mode

Have you noticed? That's not what we expected! The second rake task overrode the method value from the first one! And that's quite dangerous and unexpected if you were to use something like this:

  • rake animals:task1
# frozen_string_literal: true

# lib/tasks/animals/task1.rake

namespace :animals do
  task task1: [:environment] do
    query.destroy_all
  end

  def query
    Animal.where(status: nil)
  end
end
Enter fullscreen mode Exit fullscreen mode
  • lib/tasks/animals/task2.rake
# frozen_string_literal: true

# lib/tasks/animals/task2.rake

namespace :animals do
  task task2: [:environment] do
    query.destroy_all
  end

  def query
    Animal.all
  end
end
Enter fullscreen mode Exit fullscreen mode

And run:

rake animals:task1
Enter fullscreen mode Exit fullscreen mode

You would remove all your records instead of the desired subset!

How can we fix it?

We need to wrap our rake tasks into the Rake::DSL class like this:

  • rake animals:task1
# frozen_string_literal: true

# lib/tasks/animals/task1.rake

module Tasks
  module Animals
    class Task1

      include Rake::DSL

      def initialize
        namespace :animals do
          task task1: [:environment] do
            puts message
          end
        end
      end

      private

      def message
        'Hello world from Task 1!'
      end
    end
  end
end

Tasks::Animals::Task1.new
Enter fullscreen mode Exit fullscreen mode
  • rake animals:task2
# frozen_string_literal: true

# lib/tasks/animals/task2.rake

module Tasks
  module Animals
    class Task2

      include Rake::DSL

      def initialize
        namespace :animals do
          task task2: [:environment] do
            puts message
          end
        end
      end

      private

      def message
        'Hello world from Task 2!'
      end
    end
  end
end

Tasks::Animals::Task2.new
Enter fullscreen mode Exit fullscreen mode

And let's execute:

rake animals:task1
# => Hello world from Task 1!
rake animals:task2
# => Hello world from Task 2!
Enter fullscreen mode Exit fullscreen mode

Now everything works as expected. Let's apply the same isolation for our backfill_statuses rake task.

# frozen_string_literal: true

# lib/tasks/animals/backfill_statuses.rake

module Tasks
  module Animals
    class BackfillStatuses

      include Rake::DSL

      def initialize
        namespace :animals do
          desc "Update animal status to 'reserved' for animals created before today"
          task backfill_statuses: [:environment] do
            Animal.where(status: nil).where('created_at < ?', Time.zone.today).update_all(status: 'reserved')
          end
        end
      end

    end
  end
end

Tasks::Animals::BackfillStatuses.new
Enter fullscreen mode Exit fullscreen mode

That's it.

Add tests

The last thing that we're going to do to ensure quality is to add tests. Let's see how we can test the rake tasks.

First of all, we need to define some code to load our tasks:

# spec/support/tasks.rb

# frozen_string_literal: true

RSpec.configure do |_config|
  Rails.application.load_tasks
end
Enter fullscreen mode Exit fullscreen mode

And include it in the spec/rails_helper.rb:

# spec/rails_helper.rb

require 'support/tasks'
Enter fullscreen mode Exit fullscreen mode

Then let's add our test:

# spec/tasks/animals/backfill_statuses_spec.rb

# frozen_string_literal: true

require 'rails_helper'

RSpec.describe 'rake animals:backfill_statuses', type: :task do
  subject { Rake::Task['animals:backfill_statuses'].execute }

  let(:expected_output) do
    <<~TEXT
      Before running the rake task, there were 1 animals in the 'reserved' state.
      After running the rake task, there are now 2 animals in the 'reserved' state.
    TEXT
  end

  let(:animal_1) { create(:animal, created_at: 10.days.ago, status: nil) }
  let(:animal_2) { create(:animal, created_at: 10.days.ago, status: 'reserved') }
  let(:animal_3) { create(:animal, created_at: 10.days.ago, status: 'another_status') }
  let(:animal_4) { create(:animal, created_at: 10.days.from_now, status: nil) }

  before do
    animal_1
    animal_2
    animal_3
    animal_4
  end

  it "update animal status to 'reserved' for animals created before today" do
    expect { subject }.to change { animal_1.reload.status }.from(nil).to('reserved')
      .and output(expected_output).to_stdout

    expect(animal_2.reload.status).to eq('reserved')
    expect(animal_3.reload.status).to eq('another_status')
    expect(animal_4.reload.status).to be_nil
  end
end
Enter fullscreen mode Exit fullscreen mode

That's it.

Data Migration Gem

The third option is to use the data-migrate gem.

Let's add this gem to our project:

# Gemfile

gem 'data_migrate'
Enter fullscreen mode Exit fullscreen mode

And execute:

bundle install
Enter fullscreen mode Exit fullscreen mode

Now you can generate a data migration as you would generate a schema migration:

rails g data_migration backfill_animal_statuses

# => db/data/20230721111716_backfill_animal_statuses.rb
Enter fullscreen mode Exit fullscreen mode

Let's add some code to the generated file to check if it actually works:

# frozen_string_literal: true

class BackfillAnimalStatuses < ActiveRecord::Migration[7.0]
  def up
    puts 'Test Data Migration'
  end

  def down
    # do nothing
  end
end
Enter fullscreen mode Exit fullscreen mode

To run the migration, we need to use the following command:

rake data:migrate
# or
rake db:migrate:with_data
Enter fullscreen mode Exit fullscreen mode

And we get the following output:

== 20230721111716 BackfillAnimalStatuses: migrating ===========================
Test Data Migration
== 20230721111716 BackfillAnimalStatuses: migrated (0.0000s) ==================
Enter fullscreen mode Exit fullscreen mode

This migration can be run only once. So let's remove it and generate another one and add real code inside:

rails g data_migration backfill_animal_statuses

# => db/data/20230721112534_backfill_animal_statuses.rbb
Enter fullscreen mode Exit fullscreen mode

Here's what we get after adding our business logic code:

# db/data/20230721112534_backfill_animal_statuses.rb

# frozen_string_literal: true

class BackfillAnimalStatuses < ActiveRecord::Migration[7.0]
  def up
    Animal.where(status: nil).where('created_at < ?', Time.zone.today).update_all(status: 'reserved')
  end

  def down
    # do nothing
  end
end
Enter fullscreen mode Exit fullscreen mode

And let's run:

rake data:migrate
Enter fullscreen mode Exit fullscreen mode

Here's what we get:

== Data =======================================================================
== 20230721112534 BackfillAnimalStatuses: migrating ===========================
== 20230721112534 BackfillAnimalStatuses: migrated (0.0221s) ==================
Enter fullscreen mode Exit fullscreen mode

Basically, data migration works by the same logic as scheme migration, but instead of saving the last running migration version into the schema_migrations table, data migration saves the version into another table called data_migrations.

It's important to mention that data migration should be irreversible in most cases, but we don't want to raise an explicit error as it would prevent rollback for the scheme structure changes. Instead, we just leave the down method empty. For this reason, it would be better to design the migration in an idempotent way, to be able to run it several times if possible.

Data Migration doesn't provide any additional advantages except for what we discussed above. Therefore, we still need to think about the problems that we solved for the rake task, such as displaying output, adding transactions, optimizing DB requests, etc.

Comparison of Rake Task and Data Migration Gem

Let's compare these two solutions and decide which one we should use and under what conditions:

When does a rake task fit better?

  • When you want to have the ability to select the exact time and day when you want to run it.
  • When you want to have the ability to select the platform where you want to run it (for example, staging or production).
  • When you want to run the same rake task several times.

When does the data migration gem fit better?

  • When you want to make sure that data will be added automatically and no one forgets to run something.
  • When schema migration order is important for you (for example, schema migration adds a new column and data migration backfills this column).
  • When you want to run it on all environments without additional effort.
  • When you need to run the migration only once.

So, in general, the rake task is much more flexible and testable and can cover the same tasks as the data migration but may require more effort. Data migrations are much more strict but provide some automations and strict execution order that are connected with schema changes.

For example, the data migration gem suits very well if you need to support some database schema restructurization and you don't want to miss the data during these changes. For instance, if you need to rename a column and you want to copy the values from the old one to a new one, then set NULL=false constraint to the new one, and then completely remove the old one, the data migration gem will make this process much easier compared to using a rake task.

On the other hand, if the task is unrelated to database schema changes, a rake task might be a more suitable choice. It offers greater flexibility and testability, making it easier to manage tasks that are not directly tied to schema alterations.

Conclusion

Throughout this article, we have explored various strategies for data migration, generation, and backfilling in a Rails application. We have implemented and improved these strategies, carefully considering their advantages and disadvantages. Additionally, we have compared two primary solutions, the rake task and data migration gem, and examined their suitability in different scenarios.

Top comments (0)