🧰

Rails の本番作業を便利にする maintenance_tasks gem の紹介

に公開

はじめに

初めまして、バックエンドエンジニアの otsubo です 🙇‍♂️
この記事では Rails の本番作業を便利にしてくれる maintenance_tasks gem を紹介します!
実際に Rails アプリケーションに導入して本番作業が快適になったため、利用して分かったことや注意点をまとめました。

https://github.com/Shopify/maintenance_tasks

maintenance_tasks は本番作業の実行環境を整えてくれる gem です。特に、下記のように DB や CSV のレコード単位で処理を行う単発の作業に向いています。

  • DB の特定カラムの値を別のカラムをもとに更新する (backfill)
  • CSV のデータを DB に投入する

こういった作業は rails runner や Kubernetes の Job で特定のスクリプトを実行するのがよくある運用かと思いますが、タスクの一時停止や再開を自前で実装するのは手間がかかります。

そこで maintenance_tasks は下記のような便利機能を提供してくれます。

  • タスク定義とそのテストコードの雛形を作成するコマンド
  • タスクをバックグラウンドジョブ (Active Job) として実行する仕組み
  • タスクの実行・一時停止・再開や途中経過の確認ができる Web UI

maintenance_tasks の機能

installation に従い導入すると、bin/rails generate maintenance_tasks:task <タスク名> というコマンドでタスクの雛形を生成できます。
雛形を少し編集した README の例 のようなクラスがタスクの定義になります。

# app/tasks/maintenance/update_posts_task.rb

module Maintenance
  class UpdatePostsTask < MaintenanceTasks::Task
    # 一度に取得するレコード数
    collection_batch_size(1000)
    
    # 処理対象の全体
    def collection
      Post.all
    end

    # レコード1件毎の処理
    def process(post)
      post.update!(content: "New content!")
    end
  end
end

このタスクを実行すると、下記の Active Job に相当するジョブが開始されるようなイメージです。

class UpdatePostJob < ApplicationJob
  def perform
    Post.find_each(batch_size: 1000) do |post|
      post.update!(content: "New content!")
    end
  end
end

ただジョブを実行するだけではなく、どの位置まで処理したかを表す cursor や累積実行時間 time_running などの情報を maintenance_tasks_runs テーブル で管理してくれます。実行や途中経過の確認は、gem が生成する routes にアクセスして Web UI から行えます。

maintenance_tasks が生成してくれる Web UI。タスクの実行ボタン・タスクのソースコード・タスクの進捗(全体のうちの何パーセントか)・一時停止ボタン・キャンセルボタンが並ぶ
maintenance_tasks が生成してくれる Web UI

その他にも多くの機能があります。書ききれないほどに多くのことができるため、詳細は README を参照してください。

  • Processing Batch Collections
    • レコード1件毎ではなく batch 毎に process メソッドを実行します。ActiveRecord::Batches#in_batchescollection に指定できます。
  • Creating a CSV Task
    • CSV をアップロードして行毎に処理します。アップロードには Active Storage を利用します。
  • Tasks that don’t need a Collection
    • 反復処理を行わないような単発スクリプトを実行します。
  • Tasks with Custom Enumerators
    • DB や CSV 以外のリソースを対象にした反復処理を行います。例えば、外部 API をページネーションで呼び出しながら1件毎に処理できます。
  • Custom Task Parameters
    • 実行パラメータを付与します。パラメータを入力する Web UI も生成してくれます。
    • Active Model のバリデーションを記述でき、不正なパラメータの場合はタスクが実行されません。
      input_string(required) と input_choise と input_integer という3つの入力フォームが表示されている。数値にマイナスの値を指定しており、"Validation failed: Arguments are invalid: :input_integer must be greater than 0" のエラーメッセージが表示されている
      自動生成されるパラメータ入力の UI とバリデーション
  • Subscribing to instrumentation events
    • タスクの成功・失敗などに応じて処理を実行します。通知やログ出力に便利です。

導入するメリット

本番環境へのアクセスに関する統制面、そして利便性の両方にメリットがありました。

  • タスクの実行・一時停止を Web UI から行えます。
    • デプロイするだけで実行可能になるため、本番環境のマシンに接続して…などが不要です。
    • 途中経過を確認しつつ、異常があれば一時停止できます。
  • 過去の実行履歴も Web UI から確認できます。
    • いつ・どのタスクを・どのパラメータで実行したか、またその結果が DB で永続化されます。
  • バックグラウンドジョブ実行のプラクティスに沿った雛形を生成してくれます。
    • 後述の注意点 を意識すれば、自然とタスクの中断に耐性がある実装になります(内部で利用する job-iteration の効果です)。
    • テストコードの雛形も生成されるため、タスクをテストしやすいです。

実装時の注意点

README と重複する部分が多いですが、実装面で気を付けるべきポイントをいくつか挙げます。

gem が生成するエンドポイントの保護

maintenance_tasks を導入することで新しい routes や controller が追加されます。追加される controller はデフォルトで ActionController::Base を継承しており、基本的に誰でもアクセスできる状態です。そのため、親 controller を変更してアクセス制御を行いましょう。

# config/initializers/maintenance_tasks.rb

# gem が追加する controller の親クラス (default: `ActionController::Base`)
# 例えば Admin 画面にアクセスできる人のみが task を実行できるようにする
MaintenanceTasks.parent_controller = 'CustomAdminController'

タスクは並列実行される(実行ボタン連打に注意)

後述する内部の仕組み の通り、タスク実行ボタンを押すと maintenance_tasks_runs テーブルにレコードが1件作成され、タスクの実行状態が DB 上で管理されます。タスク毎の状態管理は gem が自動で行ってくれますが、タスク間の排他制御までは行われません。そのため、実行ボタンを複数回押すと、その数だけタスクが並列に実行されます。

並列実行数を制御したい場合は Rails アプリケーション側で行う必要があります。弊社ではまだ試せていませんが、Active Job 側で特定 queue を捌く worker の concurrency を制御したり、タスク開始直後に他の maintenance_tasks_runs レコードを確認して実行可否を判断したりすることで制御できそうです。

タスクを実行する queue は、maintenance_tasks が実行する Job クラスを指定することで設定できます。

# config/initializers/maintenance_tasks.rb

MaintenanceTasks.job = "CustomTaskJob"
# app/jobs/custom_task_job.rb

class CustomTaskJob < MaintenanceTasks::TaskJob
  queue_as :slow_queue
end

process メソッドはできるだけ高速かつ冪等に

メリット で記載したように、生成されるタスクの雛形はバックグラウンドジョブのプラクティスに沿った実装になっています。具体的には、タスク実行中にインフラ起因の中断(例えば、タスクを実行している Kubernetes の Pod がデプロイにより入れ替わるなど)が発生したとしても、Graceful Shutdown の条件を満たせば process メソッド単体は実行し終えてからジョブが終了します。そして、インフラが復旧した後は中断位置の次のレコードから process が再開されます。

ポイントは「Graceful Shutdown の条件を満たせば」という部分です。process メソッドに時間がかかりすぎると、process メソッドの途中でジョブが強制終了し、最悪の場合ジョブがうまくリキューされずにロストしてしまう可能性があります。具体的な時間はインフラと queue adapter の設定によって変わりますが、Sidekiq の場合はタイムアウトオプション(デフォルトは 25 秒)以内に process メソッドを完了する必要があります。

また、Graceful Shutdown できなかった場合、リトライ時に process メソッドが同じ引数で2回以上実行される可能性があります。そのため、process メソッドはできるだけ冪等性を保つように実装しましょう。

詳しくは README の Considerations when writing Tasks セクションや、job-iteration のガイド Iteration: how it works を参照してください。

タスクインスタンスのライフサイクルはジョブと同じ

この記事では、読みやすさを重視して「タスクの実行」「ジョブの実行」を厳密に区別せず使っていますが、ジョブとタスクは異なる概念です。エンキューされて実行されるのはジョブであり、ジョブの中でタスクに定義した処理を実行していくという関係です。具体的にコードを見ていくと、 ジョブ (MaintenanceTasks::TaskJob) の開始直前のコールバック before_perform でタスク(例えば SampleTask)をインスタンス化し、

https://github.com/Shopify/maintenance_tasks/blob/48493beec96c49b39610fa6a2c417dd74ee2eb53/app/jobs/concerns/maintenance_tasks/task_job_concern.rb#L118-L120

ジョブの実行中はそのインスタンスを使い回します。

https://github.com/Shopify/maintenance_tasks/blob/48493beec96c49b39610fa6a2c417dd74ee2eb53/app/jobs/concerns/maintenance_tasks/task_job_concern.rb#L107-L112

そのため、下記のようにタスクのインスタンス変数 @processed_count をカウントアップするコードは一応動作します。一時停止や中断を挟まない場合は、最終的な @processed_countcollection の件数と一致します。

module Maintenances
  class SampleTask < MaintenanceTasks::Task
    collection_batch_size(20)

    after_pause :log_processed_count
    after_complete :log_processed_count

    def log_processed_count
      p "Processed count: #{@processed_count}"
    end

    def collection
      Post.all
    end

    def process(_element)
      @processed_count ||= 0
      @processed_count += 1
    end
  end
end

ただし、一時停止や中断によってジョブが再作成される場合、タスクのインスタンスも再作成されるため @processed_count はリセットされます。例えば、collection が100件のレコードの場合、

  1. 実行
  2. 一時停止 Processed count: 15
  3. 再開
  4. 完了 Processed count: 85(最初の15件は含まれない)

のようになります。さらにインフラ起因の中断がある場合は、 @processed_count は中断から再開した後の件数になるため、集計操作を行う場合は途中経過を DB 上に保存するなどの工夫が必要です。

内部の仕組み

maintenance_tasks が裏側でどのように動作しているのかを見るため、シンプルなタスクを実行してジョブが発行する SQL を観察してみました。実行環境と、実行したタスクは下記の通りです。

  • Ruby 3.3.8
  • Rails 7.2.2.1
  • Sidekiq 8.0.4
module Maintenances
  class SampleTask < MaintenanceTasks::Task
    collection_batch_size(20)

    def collection
      Post.all # 全部で100件のレコードがあるテーブル
    end

    def process(_element)
      sleep 0.1 # gem が発行する SQL を観察するため、ユーザ定義の処理は何も行わない
    end
  end
end

100件のうち、24件処理したタイミングで Pause ボタンを押下して一時停止させました。その時の Sidekiq ジョブのログが下記になります。簡単にするため TRANSACTION BEGIN/COMMIT のログは省略し、適宜コメントや改行を入れています。

-- 実行開始時の処理
-- maintenance_tasks_runs.status を "enqueued" --> "running" に更新
-- maintenance_tasks_runs.tick_total に対象の全件数を保存
MaintenanceTasks::Run Load (2.3ms)  SELECT `maintenance_tasks_runs`.* FROM `maintenance_tasks_runs` WHERE `maintenance_tasks_runs`.`id` = 1 LIMIT 1
MaintenanceTasks::Run Update (0.2ms)  UPDATE `maintenance_tasks_runs` SET `maintenance_tasks_runs`.`status` = 'running', `maintenance_tasks_runs`.`updated_at` = '2025-06-16 06:00:41.829229', `maintenance_tasks_runs`.`lock_version` = 1 WHERE `maintenance_tasks_runs`.`id` = 1 AND `maintenance_tasks_runs`.`lock_version` = 0
Post Count (0.5ms)  SELECT COUNT(*) FROM `posts`
MaintenanceTasks::Run Update (0.2ms)  UPDATE `maintenance_tasks_runs` SET `maintenance_tasks_runs`.`started_at` = '2025-06-16 06:00:41', `maintenance_tasks_runs`.`tick_total` = 100, `maintenance_tasks_runs`.`updated_at` = '2025-06-16 06:00:41.836991', `maintenance_tasks_runs`.`lock_version` = 2 WHERE `maintenance_tasks_runs`.`id` = 1 AND `maintenance_tasks_runs`.`lock_version` = 1

-- collection から取得(1つめの batch)
Post Load (0.2ms)  SELECT `posts`.* FROM `posts` ORDER BY posts.id LIMIT 20

-- process 毎に maintenance_tasks_runs.status を確認
MaintenanceTasks::Run Pluck (0.3ms)  SELECT `maintenance_tasks_runs`.`status`, `maintenance_tasks_runs`.`lock_version` FROM `maintenance_tasks_runs` WHERE `maintenance_tasks_runs`.`id` = 1
MaintenanceTasks::Run Pluck (0.4ms)  SELECT `maintenance_tasks_runs`.`status`, `maintenance_tasks_runs`.`lock_version` FROM `maintenance_tasks_runs` WHERE `maintenance_tasks_runs`.`id` = 1
MaintenanceTasks::Run Pluck (1.4ms)  SELECT `maintenance_tasks_runs`.`status`, `maintenance_tasks_runs`.`lock_version` FROM `maintenance_tasks_runs` WHERE `maintenance_tasks_runs`.`id` = 1
MaintenanceTasks::Run Pluck (1.2ms)  SELECT `maintenance_tasks_runs`.`status`, `maintenance_tasks_runs`.`lock_version` FROM `maintenance_tasks_runs` WHERE `maintenance_tasks_runs`.`id` = 1
MaintenanceTasks::Run Pluck (0.6ms)  SELECT `maintenance_tasks_runs`.`status`, `maintenance_tasks_runs`.`lock_version` FROM `maintenance_tasks_runs` WHERE `maintenance_tasks_runs`.`id` = 1
MaintenanceTasks::Run Pluck (0.5ms)  SELECT `maintenance_tasks_runs`.`status`, `maintenance_tasks_runs`.`lock_version` FROM `maintenance_tasks_runs` WHERE `maintenance_tasks_runs`.`id` = 1
MaintenanceTasks::Run Pluck (1.4ms)  SELECT `maintenance_tasks_runs`.`status`, `maintenance_tasks_runs`.`lock_version` FROM `maintenance_tasks_runs` WHERE `maintenance_tasks_runs`.`id` = 1
MaintenanceTasks::Run Pluck (0.9ms)  SELECT `maintenance_tasks_runs`.`status`, `maintenance_tasks_runs`.`lock_version` FROM `maintenance_tasks_runs` WHERE `maintenance_tasks_runs`.`id` = 1
MaintenanceTasks::Run Pluck (1.0ms)  SELECT `maintenance_tasks_runs`.`status`, `maintenance_tasks_runs`.`lock_version` FROM `maintenance_tasks_runs` WHERE `maintenance_tasks_runs`.`id` = 1

-- tick (1秒) 毎にタスクの進捗を更新
MaintenanceTasks::Run Update All (3.6ms)  UPDATE `maintenance_tasks_runs` SET `maintenance_tasks_runs`.`tick_count` = COALESCE(`maintenance_tasks_runs`.`tick_count`, 0) + 10, `maintenance_tasks_runs`.`time_running` = COALESCE(`maintenance_tasks_runs`.`time_running`, 0) + 1.08908025, `maintenance_tasks_runs`.`lock_version` = COALESCE(`maintenance_tasks_runs`.`lock_version`, 0) + 1, `maintenance_tasks_runs`.`updated_at` = '2025-06-16 06:00:42.931541' WHERE `maintenance_tasks_runs`.`id` = 1

-- process 毎に maintenance_tasks_runs.status を確認(続き)
MaintenanceTasks::Run Pluck (0.4ms)  SELECT `maintenance_tasks_runs`.`status`, `maintenance_tasks_runs`.`lock_version` FROM `maintenance_tasks_runs` WHERE `maintenance_tasks_runs`.`id` = 1
MaintenanceTasks::Run Pluck (1.2ms)  SELECT `maintenance_tasks_runs`.`status`, `maintenance_tasks_runs`.`lock_version` FROM `maintenance_tasks_runs` WHERE `maintenance_tasks_runs`.`id` = 1
MaintenanceTasks::Run Pluck (1.9ms)  SELECT `maintenance_tasks_runs`.`status`, `maintenance_tasks_runs`.`lock_version` FROM `maintenance_tasks_runs` WHERE `maintenance_tasks_runs`.`id` = 1
MaintenanceTasks::Run Pluck (1.0ms)  SELECT `maintenance_tasks_runs`.`status`, `maintenance_tasks_runs`.`lock_version` FROM `maintenance_tasks_runs` WHERE `maintenance_tasks_runs`.`id` = 1
MaintenanceTasks::Run Pluck (1.1ms)  SELECT `maintenance_tasks_runs`.`status`, `maintenance_tasks_runs`.`lock_version` FROM `maintenance_tasks_runs` WHERE `maintenance_tasks_runs`.`id` = 1
MaintenanceTasks::Run Pluck (1.0ms)  SELECT `maintenance_tasks_runs`.`status`, `maintenance_tasks_runs`.`lock_version` FROM `maintenance_tasks_runs` WHERE `maintenance_tasks_runs`.`id` = 1
MaintenanceTasks::Run Pluck (0.7ms)  SELECT `maintenance_tasks_runs`.`status`, `maintenance_tasks_runs`.`lock_version` FROM `maintenance_tasks_runs` WHERE `maintenance_tasks_runs`.`id` = 1
MaintenanceTasks::Run Pluck (7.7ms)  SELECT `maintenance_tasks_runs`.`status`, `maintenance_tasks_runs`.`lock_version` FROM `maintenance_tasks_runs` WHERE `maintenance_tasks_runs`.`id` = 1
MaintenanceTasks::Run Pluck (4.3ms)  SELECT `maintenance_tasks_runs`.`status`, `maintenance_tasks_runs`.`lock_version` FROM `maintenance_tasks_runs` WHERE `maintenance_tasks_runs`.`id` = 1

-- tick (1秒) 毎にタスクの進捗を更新
MaintenanceTasks::Run Update All (3.1ms)  UPDATE `maintenance_tasks_runs` SET `maintenance_tasks_runs`.`tick_count` = COALESCE(`maintenance_tasks_runs`.`tick_count`, 0) + 9, `maintenance_tasks_runs`.`time_running` = COALESCE(`maintenance_tasks_runs`.`time_running`, 0) + 1.042947584, `maintenance_tasks_runs`.`lock_version` = COALESCE(`maintenance_tasks_runs`.`lock_version`, 0) + 1, `maintenance_tasks_runs`.`updated_at` = '2025-06-16 06:00:43.966657' WHERE `maintenance_tasks_runs`.`id` = 1

-- process 毎に maintenance_tasks_runs.status を確認(続き)
MaintenanceTasks::Run Pluck (0.4ms)  SELECT `maintenance_tasks_runs`.`status`, `maintenance_tasks_runs`.`lock_version` FROM `maintenance_tasks_runs` WHERE `maintenance_tasks_runs`.`id` = 1
MaintenanceTasks::Run Pluck (1.6ms)  SELECT `maintenance_tasks_runs`.`status`, `maintenance_tasks_runs`.`lock_version` FROM `maintenance_tasks_runs` WHERE `maintenance_tasks_runs`.`id` = 1

-- collection から取得(2つめの batch)
Post Load (0.7ms)  SELECT `posts`.* FROM `posts` WHERE (posts.id > '20') ORDER BY posts.id LIMIT 20

-- process 毎に maintenance_tasks_runs.status を確認
MaintenanceTasks::Run Pluck (1.2ms)  SELECT `maintenance_tasks_runs`.`status`, `maintenance_tasks_runs`.`lock_version` FROM `maintenance_tasks_runs` WHERE `maintenance_tasks_runs`.`id` = 1
MaintenanceTasks::Run Pluck (1.4ms)  SELECT `maintenance_tasks_runs`.`status`, `maintenance_tasks_runs`.`lock_version` FROM `maintenance_tasks_runs` WHERE `maintenance_tasks_runs`.`id` = 1
MaintenanceTasks::Run Pluck (0.6ms)  SELECT `maintenance_tasks_runs`.`status`, `maintenance_tasks_runs`.`lock_version` FROM `maintenance_tasks_runs` WHERE `maintenance_tasks_runs`.`id` = 1
MaintenanceTasks::Run Pluck (0.3ms)  SELECT `maintenance_tasks_runs`.`status`, `maintenance_tasks_runs`.`lock_version` FROM `maintenance_tasks_runs` WHERE `maintenance_tasks_runs`.`id` = 1

-- 一時停止を検出して maintenance_tasks_runs.status を "pausing" --> "paused" に更新
-- 一時停止位置を maintenance_tasks_runs.cursor に保存
MaintenanceTasks::Run Update All (1.4ms)  UPDATE `maintenance_tasks_runs` SET `maintenance_tasks_runs`.`tick_count` = COALESCE(`maintenance_tasks_runs`.`tick_count`, 0) + 5, `maintenance_tasks_runs`.`time_running` = COALESCE(`maintenance_tasks_runs`.`time_running`, 0) + 0.540926333, `maintenance_tasks_runs`.`lock_version` = COALESCE(`maintenance_tasks_runs`.`lock_version`, 0) + 1, `maintenance_tasks_runs`.`updated_at` = '2025-06-16 06:00:44.507424' WHERE `maintenance_tasks_runs`.`id` = 1
MaintenanceTasks::Run Update (0.2ms)  UPDATE `maintenance_tasks_runs` SET `maintenance_tasks_runs`.`status` = 'paused', `maintenance_tasks_runs`.`cursor` = '24', `maintenance_tasks_runs`.`updated_at` = '2025-06-16 06:00:44.510166', `maintenance_tasks_runs`.`lock_version` = 7 WHERE `maintenance_tasks_runs`.`id` = 1 AND `maintenance_tasks_runs`.`lock_version` = 6

タスク実行ボタンを押下すると maintenance_tasks_runs レコードが controller 側で作成されます。レコード作成はタスク実行ボタンを押下した時に呼ばれるアクション MaintenanceTasks::RunsController#create 内で Runner が行います。

https://github.com/Shopify/maintenance_tasks/blob/48493beec96c49b39610fa6a2c417dd74ee2eb53/app/controllers/maintenance_tasks/runs_controller.rb#L13-L20

https://github.com/Shopify/maintenance_tasks/blob/48493beec96c49b39610fa6a2c417dd74ee2eb53/app/models/maintenance_tasks/runner.rb#L22-L25

ジョブが開始されると、maintenance_tasks_runs テーブルから id=1 のレコードを SELECT し、状態を running にしつつ全件数などのメタ情報を保存しているのが分かります。「タスクの実行1回」がレコード1つに紐付き、一時停止や再開はレコードの status を切り替えることで管理しているようです。

MaintenanceTasks::Run Load (2.3ms)  SELECT `maintenance_tasks_runs`.* FROM `maintenance_tasks_runs` WHERE `maintenance_tasks_runs`.`id` = 1 LIMIT 1
MaintenanceTasks::Run Update (0.2ms)  UPDATE `maintenance_tasks_runs` SET `maintenance_tasks_runs`.`status` = 'running', `maintenance_tasks_runs`.`updated_at` = '2025-06-16 06:00:41.829229', `maintenance_tasks_runs`.`lock_version` = 1 WHERE `maintenance_tasks_runs`.`id` = 1 AND `maintenance_tasks_runs`.`lock_version` = 0
Post Count (0.5ms)  SELECT COUNT(*) FROM `posts`
MaintenanceTasks::Run Update (0.2ms)  UPDATE `maintenance_tasks_runs` SET `maintenance_tasks_runs`.`started_at` = '2025-06-16 06:00:41', `maintenance_tasks_runs`.`tick_total` = 100, `maintenance_tasks_runs`.`updated_at` = '2025-06-16 06:00:41.836991', `maintenance_tasks_runs`.`lock_version` = 2 WHERE `maintenance_tasks_runs`.`id` = 1 AND `maintenance_tasks_runs`.`lock_version` = 1

続いて collection_batch_size(20) で指定した件数のレコードを collection から取得しています。

Post Load (0.2ms)  SELECT `posts`.* FROM `posts` ORDER BY posts.id LIMIT 20

その後 process メソッドを1件ずつ処理している様子が分かります。process を実行するたびに maintenance_tasks_runs レコードを取得し、status を確認してタスクの中断や一時停止を検出しています。

MaintenanceTasks::Run Pluck (0.3ms)  SELECT `maintenance_tasks_runs`.`status`, `maintenance_tasks_runs`.`lock_version` FROM `maintenance_tasks_runs` WHERE `maintenance_tasks_runs`.`id` = 1
MaintenanceTasks::Run Pluck (0.4ms)  SELECT `maintenance_tasks_runs`.`status`, `maintenance_tasks_runs`.`lock_version` FROM `maintenance_tasks_runs` WHERE `maintenance_tasks_runs`.`id` = 1
MaintenanceTasks::Run Pluck (1.4ms)  SELECT `maintenance_tasks_runs`.`status`, `maintenance_tasks_runs`.`lock_version` FROM `maintenance_tasks_runs` WHERE `maintenance_tasks_runs`.`id` = 1
......

途中で挟まれる maintenance_tasks_runs レコードの UPDATE は、タスクの進捗状況の更新です。この定期更新は、MaintenanceTasks.ticker_delay (デフォルト1秒)の間隔で呼ばれています。

MaintenanceTasks::Run Update All (3.1ms)  UPDATE `maintenance_tasks_runs` SET `maintenance_tasks_runs`.`tick_count` = COALESCE(`maintenance_tasks_runs`.`tick_count`, 0) + 9, `maintenance_tasks_runs`.`time_running` = COALESCE(`maintenance_tasks_runs`.`time_running`, 0) + 1.042947584, `maintenance_tasks_runs`.`lock_version` = COALESCE(`maintenance_tasks_runs`.`lock_version`, 0) + 1, `maintenance_tasks_runs`.`updated_at` = '2025-06-16 06:00:43.966657' WHERE `maintenance_tasks_runs`.`id` = 1

最後のログは、Pause ボタン押下に反応してタスクの状態を paused にするログです。

Pause ボタンを押下すると MaintenanceTasks::RunsController#pause でタスクの状態が pausing になります。

https://github.com/Shopify/maintenance_tasks/blob/48493beec96c49b39610fa6a2c417dd74ee2eb53/app/controllers/maintenance_tasks/runs_controller.rb#L34-L39

そのため、process メソッド毎に行っている状態確認で一時停止を検出できます。一時停止するときに、どの位置まで処理したかを cursor に保存し、再開時にその位置から処理を始められるようにしています。

MaintenanceTasks::Run Update All (1.4ms)  UPDATE `maintenance_tasks_runs` SET `maintenance_tasks_runs`.`tick_count` = COALESCE(`maintenance_tasks_runs`.`tick_count`, 0) + 5, `maintenance_tasks_runs`.`time_running` = COALESCE(`maintenance_tasks_runs`.`time_running`, 0) + 0.540926333, `maintenance_tasks_runs`.`lock_version` = COALESCE(`maintenance_tasks_runs`.`lock_version`, 0) + 1, `maintenance_tasks_runs`.`updated_at` = '2025-06-16 06:00:44.507424' WHERE `maintenance_tasks_runs`.`id` = 1
MaintenanceTasks::Run Update (0.2ms)  UPDATE `maintenance_tasks_runs` SET `maintenance_tasks_runs`.`status` = 'paused', `maintenance_tasks_runs`.`cursor` = '24', `maintenance_tasks_runs`.`updated_at` = '2025-06-16 06:00:44.510166', `maintenance_tasks_runs`.`lock_version` = 7 WHERE `maintenance_tasks_runs`.`id` = 1 AND `maintenance_tasks_runs`.`lock_version` = 6

発行される SQL をまとめると、以下の表のようになります。

タイミング SQL
タスクの開始・中断時 UPDATE maintenance_tasks_runsstatus などを更新
batch 毎 collectionの取得
process 毎 SELECT status, lock_version FROM maintenance_tasks_runs
tick 毎(1秒毎) UPDATE maintenance_tasks_runstick_count(累計処理件数)や time_running(累積実行時間)を更新

おわりに

maintenance_tasks の機能や仕組みを紹介しました。本番作業に重宝しているため、良さが伝われば幸いです!

余談ですが、この記事を書いている途中に Active Job Continuations という機能が Active Job に追加されました(2025/5/30/this-week-in-rails より)。maintenance_tasks が内部で利用している job-iteration にインスパイアされて作られた、ジョブに中断耐性を持たせる機能のようです。この記事でも触れたジョブ実行のプラクティスが Rails コアにも影響を与えているのを見て「おおっ」と思いました。

Social PLUS Tech Blog

Discussion