Web Workerにも書いているが、Sidekiqのジョブに進捗を表示したいと思っている。 例えばNode.jsのBullというライブラリではあらかじめプログレスの表示機能が利用できる。 良くも悪くもSidekiqにはそこまで親切な機能はないため、自分で足りない機能を実装しなければならない。

一度だけやったことはあるのでできなくもないのだが、RailsでRedisのクライアントを直接呼び出すのは少々無骨すぎる。

そこで少し調べると、Railsがリリースしているkredisというライブラリがあるようだ。 今回はこのライブラリについてまとめていく。

余談だがGo Railsのビデオを見る限りでは*K-Redis(ケーレディス)ではなくKredis(クレディス)*と発音していた。

基本

まずはkredisをインストールする。 この投稿時点では以下のバージョンで進める。

kredis (1.5.0)

READMEにも記載があるが、./bin/rails kredis:installコマンドを実行して生成されたYAMLファイルを編集する。

# config/redis/shared.yml
production: &production
  url: <%= ENV.fetch("REDIS_URL", "redis://127.0.0.1:6379/0") %>
  timeout: 1

development: &development
  url: <%= ENV.fetch("REDIS_URL", "redis://127.0.0.1:6379/0") %>
  timeout: 1

  # You can also specify host, port, and db instead of url
  # host: <%= ENV.fetch("REDIS_SHARED_HOST", "127.0.0.1") %>
  # port: <%= ENV.fetch("REDIS_SHARED_PORT", "6379") %>
  # db: <%= ENV.fetch("REDIS_SHARED_DB", "11") %>

test:
  <<: *development
  db: <%= ENV.fetch("REDIS_SHARED_DB", "1") %>

デフォルトで生成されるファイルはdevelopment環境とtest環境で同じDBを参照しているのでテストコマンドの結果が変わってしまうのを防ぐためにdbを指定する。

モデルの定義

class Item < ApplicationRecord
  kredis_integer :progress
end

SQLのようにmigrationファイルを用意する必要はないので、モデルに直接kredis_+型を指定する。 最初はkredis_counterを指定していたが、incrementあるいはsetしかサポートしていなかったのでkredis_integerにした。

またモデル経由で使う場合はすでにDBにコミットされている状態でなければならない。 初期化しただけのモデルでprogressを呼び出そうとするとエラーが発生する。

item = Item.create
=> #<Item:0x00007f19451bb880 id: 1, progress: nil, created_at: Sat, 14 Oct 2023 19:33:45.245662027 JST +09:00, updated_at: Sat, 14 Oct 2023 19:33:45.245662027 JST +09:00>
item.progress.value
  Kredis  (1.1ms)  Connected to shared
  Kredis Proxy (0.9ms)  GET items:1:progress
=> nil

注意点としてはkredis_integerの初期値はnilである。 現在GitHubで公開されているmainブランチではkredis_integer :progress, default: 0をサポートしている。 kredisのリリースが待ち遠しいが、to_iでも回避できるのでそこまで致命的なものでもない。

UPDATE: 幸いなことに1.6.0が早くもリリースされた。ありがとうDHH。

item.progress.value = 1
  Kredis Proxy (0.4ms)  SET items:1:progress [1]
=> 1
item.progress.value
  Kredis Proxy (0.3ms)  GET items:1:progress
=> 1

value=あるいはvalueで値を参照できる。

item.progress.key
=> "items:1:progress"

keyは実際にRedisで使われているキーを取得できる。

redis = Redis.new(host: 'localhost', port: 6379)
=> #<Redis client v5.0.7 for redis://localhost:6379/0>
redis.keys('*').include?(item.progress.key)
=> true

見たところSidekiqのキーと衝突はしないと思うが、ひょっとするとkredis/items:1:progressとかに書き換えたほうが安全かもしれない。

SQLiteとの比較

もともとはモデルに対してintegerのカラムを追加して、都度updateしていく実装だった。 データベースに値をコミットしていくのも悪くはないのだが、単純にUPDATEを実行するだけでもトランザクションが実行されるから安全である反面やっぱり時間がかかっている感じは否めない。

今回はbenchmark-ipsというgemを利用する。

Itemクラスにprogressというカラムを用意して、もう片方はredis_progressを定義しておき、それぞれ数値をセットするときのパフォーマンスを比較する。

require "benchmark/ips"

ActiveRecord::Base.connection.execute <<-SQL
  CREATE TABLE IF NOT EXISTS items (
      id INTEGER PRIMARY KEY,
      progress INTEGER,
      created_at DATETIME,
      updated_at DATETIME
  );
SQL

class Item < ApplicationRecord
  kredis_integer :redis_progress
end

n = 100
a = Item.create
b = Item.create
Benchmark.ips(2) do |x|
  x.report("sqlite") do
    n.times do |i|
      a.update!(progress: i)
    end
  end

  x.report("kredis") do
    n.times do |i|
      b.redis_progress.value = i
    end
  end

  x.compare!
end

ActiveRecord::Base.connection.execute("DROP TABLE items;")

SQLite3をセットアップしたRailsのrails runnerコマンドを実行した:

Warming up --------------------------------------
              sqlite     1.000  i/100ms
              kredis     5.000  i/100ms
Calculating -------------------------------------
              sqlite      2.050  (± 0.0%) i/s -      5.000  in   2.462847s
              kredis     64.403  (±10.9%) i/s -    130.000  in   2.051623s

Comparison:
              kredis:       64.4 i/s
              sqlite:        2.1 i/s - 31.41x  slower

体感上でも差はあったが、数値にしてみると想像以上に差があった。

ここまで差があるとやはりSQLiteよりもRedisでプログレスを管理する方がよさそうな気がする。

Redisとの比較

では直接Redisクライアントと比較するとどうだろうか。

どちらもテーブル名は同じだが、RedisItemクラスはクラス変数にRedisのインスタンスを用意しておき、getあるいはsetで値の更新と取得を繰り返す。

require "benchmark/ips"

ActiveRecord::Base.connection.execute <<-SQL
  CREATE TABLE IF NOT EXISTS items (
      id INTEGER PRIMARY KEY,
      created_at DATETIME,
      updated_at DATETIME
  );
SQL

class RedisItem < ApplicationRecord
  self.table_name = "items"

  @@redis = Redis.new(host: "localhost", port: 6379)

  def progress
    @@redis.get("redis_item:#{id}:progress")
  end

  def progress=(value)
    @@redis.set("redis_item:#{id}:progress", value)
  end
end

class KredisItem < ApplicationRecord
  self.table_name = "items"

  kredis_integer :progress
end

n = 100
r = RedisItem.create
k = KredisItem.create
Benchmark.ips(2) do |x|
  x.report("redis") do
    n.times do |i|
      r.progress = i
      r.progress.to_i
    end
  end

  x.report("kredis") do
    n.times do |i|
      k.progress.value = i
      k.progress.value.to_i
    end
  end

  x.compare!
end

ActiveRecord::Base.connection.execute("DROP TABLE items;")

早速実行してみよう:

Warming up --------------------------------------
               redis     5.000  i/100ms
              kredis     2.000  i/100ms
Calculating -------------------------------------
               redis     50.752  (±17.7%) i/s -    100.000  in   2.030080s
              kredis     27.670  (±14.5%) i/s -     54.000  in   2.001809s

Comparison:
               redis:       50.8 i/s
              kredis:       27.7 i/s - 1.83x  slower

およそ2倍近くパフォーマンスに差が出るようだった。 この値を大きいと捉えるか、あるいは小さいと捉えるかの違いだ。

そこまで複雑でもないので自分で実装しても良い気もする。 しかしSQLiteの差ほど大きくは感じないし、極力Redisクライアントを意識したくはないので私はライブラリを採用する方を選んだ。