Rails: 5年前のアドバイザリーロック実装が突然おかしくなった話(翻訳)
最近私たちは、アドバイザリーロック(勧告的ロック)を取得するときのロックキーの算出部分に間違いを発見しました。これについては過去のreadモデルの構築記事を更新する形でカバー済みですが、この問題の全容をお伝えすることは皆さんにとっても興味深いものになるだろうと思いました。
アドバイザリーロックが必要になる理由
以下の非同期イベントハンドラをご覧ください。ここには以下の2つのコンテキストがバインドされています。
Banking
- サードパーティサービスの銀行口座(bank account)の技術情報を管理する。
Accounting
- 簿記業務(初期残高とその後の残高変化を特定の勘定科目(account)に入れるなど)のコンテキストにおいて、銀行口座に関心を持つ。
Banking
に銀行口座が作成されると、その銀行口座をAccounting
に反映し、その銀行口座の簿記id(bookkeeping identifier)を提供する必要があります。これは、「すべての銀行口座は親コード512000
を持ち、それに続く形で512101
、512102
、512103
などのコードも持つ必要がある」といった特定のルールに基づいて行われます。
# frozen_string_literal: true
module Accounting
class CreateBankAccount < Infra::EventHandler
def call(event)
tenant = ::Account.find(event.data.fetch(:tenant_id))
bank_account = ::BankAccount.find(event.data.fetch(:id))
bookkeeping_account = BookkeepingAccount.for(bank_account)
BankAccount.create!(
tenant: tenant,
name: "#{bookkeeping_account.code} -- Bank",
display_name: "Bank",
code: bookkeeping_account.code, # 例: 512101
parent_code: bookkeeping_account.parent_code, # 例: 512000
)
end
end
end
上のコードはコンカレント処理で問題が発生しやすくなります。このビジネスロジックでは、指定のテナントに対して同じcode
で2つのAccounting::BankAccount
を作成することは許されません。じきにActiveRecord::RecordNotUnique
エラーが発生するのは明らかです。
アドバイザリーロックを導入した
私たちが2017年に別のプロジェクトを手掛けていたときに、アドバイザリーロックを用いて悲観的ロック、具体的にはpg_advisory_xact_lock(key bigint)
を実装してはどうだろうと思い付きました。
pg_advisory_xact_lock
の動作はpg_advisory_lock
と同じですが、現在のトランザクションの終了時に自動的にロックが解放される点が異なるので、明示的なロックの解放はできません。
pg_advisory_lock
は、アプリケーションが定義したリソースをロックします。キーは単一の64ビットキー値、または、2つの32ビットキー(この2つのキー空間は重複しないことに注意)によって識別されます。
pg_advisory_xact_lock
に渡されるbig integerを生成する方法が必要でした。RubyのObject#hash
ドキュメントに「このオブジェクトのInteger
ハッシュ値を生成する」と書かれているので、これを用いるのが自然に思えました。
この仮説を手っ取り早く検証してみましょう。
uuid = "a2e920fd-c51a-44a8-924d-5dc6aaba9884"
lock_nr = uuid.hash
ActiveRecord::Base.transaction do
puts "trying to obtain lock - #{Time.now}"
ActiveRecord::Base.connection.execute "SELECT pg_advisory_xact_lock(#{lock_nr})"
puts "lock granted, sleeping - #{Time.now}"
sleep(50)
end
puts "lock released - #{Time.now}"
ロック1:
(0.5ms) BEGIN
trying to obtain lock - 2017-06-28 10:05:44 +0200
(0.7ms) SELECT pg_advisory_xact_lock(1924743033351481473)
lock granted, sleeping - 2017-06-28 10:05:44 +0200
ロック2:
trying to obtain lock - 2017-06-28 10:05:46 +0200
(48570.8ms) SELECT pg_advisory_xact_lock(1924743033351481473)
lock granted, sleeping - 2017-06-28 10:06:34 +0200
概念実証はうまくいきましたので、これを実装してみましょう。
# frozen_string_literal: true
class ApplicationRecord < ActiveRecord::Base
self.abstract_class = true
def self.with_advisory_lock(*args)
transaction do
bigint = args.join.hash
ApplicationRecord.connection.execute("SELECT pg_advisory_xact_lock(#{bigint})")
yield
end
end
end
module Accounting
class CreateBankAccount < Infra::EventHandler
def call(event)
tenant = ::Account.find(event.data.fetch(:tenant_id))
bank_account = ::BankAccount.find(event.data.fetch(:id))
ApplicationRecord.with_advisory_lock(tenant.id) do
bookkeeping_account = BookkeepingAccount.for(bank_account)
BankAccount.create!(
tenant: tenant,
name: "#{bookkeeping_account.code} -- Bank",
display_name: "Bank",
code: bookkeeping_account.code,
parent_code: bookkeeping_account.parent_code,
)
end
end
end
end
テナントのスコープ内で一意性を保証する必要があったので、big integerの算出ではtenant_id
を入力値として受け取りました。
コンカレンシーの問題をテストする
2015年にRobertがRailsアプリの競合状態をテストする記事を書いていたので、私たちは既にコンカレントなコードのテスト方法について十分理解しているはずですよね?
# frozen_string_literal: true
require_relative "test_helper"
require "database_cleaner/active_record"
module Accounting
class OnBankAccountCreatedConcurrencyTest < TestCase
self.use_transactional_tests = false
setup { DatabaseCleaner.strategy = [:truncation] }
def test_concurrency
begin
concurrency_level = ActiveRecord::Base.connection.pool.size - 1
assert concurrency_level >= 4
bank_accounts = concurrency_level.times.map { create_bank_account }
fail_occurred = false
wait_for_it = true
Thread.abort_on_exception = true
threads =
concurrency_level.times.map do |i|
Thread.new do
true while wait_for_it
begin
Accounting::CreateBankAccount.new.call(
Banking::BankAccountCreated.new(data: { tenant_id: 2137, id: bank_accounts.fetch(i).id }),
)
rescue ActiveRecord::RecordNotUnique
fail_occurred = true
end
end
end
wait_for_it = false
threads.each(&:join)
refute fail_occurred
assert_equal 4, Accounting::BankAccount.of_tenant(2137).where(parent_code: 512_000).size
ensure
ActiveRecord::Base.connection_pool.disconnect!
end
end
teardown { DatabaseCleaner.clean }
private
def create_bank_account
BankAccount.create!(
connector_id: 12_345,
balance_currency: Money::Currency.new("EUR").iso_code,
balance_value: 1_000_000.00,
external_id: SecureRandom.uuid,
)
end
end
end
このテストはパスし、コードが正しく動いていることがわかったので、コードをproduction環境でリリースしてぐっすり眠りにつきました。
忘れた頃に突然おかしくなった
数年後、ActiveRecord::RecordNotUnique
エラーという手痛いしっぺ返しを食らいました。私たちはこの原因について俄然興味を惹かれましたが、手がかりはまったくつかめませんでした。このコードはSidekiqで非同期に実行され、失敗するとリトライしたので、問題は自己修復されていたのです。簡易調査では答えが見つかりませんでした。これはアプリでは問題にはならなかったものの、Honeybadgerで頻発しており、そのたびに開発者たちは頭を抱えました。
そのとき、ふとチームメンバーの1人の頭に過去のプロジェクトの記憶がよぎりました。当時そのプロジェクトでは、同じような方法でアドバイザリーロックを取得すると、時たま同じような問題が発生していたというのです。ここ数か月の間に両プロジェクトの技術スタックで何か相違点がなかったかどうかについて議論を始めました。
「あ、そういえばSidekiqのプロセスを1個追加してたわ」
ただちにirb
プロセスを2つ個別に立ち上げて、それが原因かどうかを調べてみました。
プロセス1:
irb(main):001:0> 123456.hash
=> -169614201293062129
プロセス2:
irb(main):001:0> 123456.hash
=> -4474522856021669622
「これだ!ドッカン爆発真っ黒け...」
ロックキーを「正しく」算出する
advisory_lock
メソッドの当初の実装は、異なるMRIプロセスに一意のハッシュ値を提供しておらず、コードがActiveRecord::RecordNotUnique
エラーを連発していました。
# frozen_string_literal: true
class ApplicationRecord < ActiveRecord::Base
self.abstract_class = true
def self.with_advisory_lock(*args)
transaction do
bigint = args.join.hash
ApplicationRecord.connection.execute("SELECT pg_advisory_xact_lock(#{bigint})")
yield
end
end
end
以前のadvisory_lock
が期待通りに動作しておらず、同じリソースを共有してしまったことを証明するには、2つの異なるデータベース接続を用いる2つの異なるプロセスが必要でした。そして開発環境もCIも、テストのセットアップがこの基準を満たしていませんでした。
RubyのObject#hash
ドキュメントに、この問題の核心となる重要な記述があります。
あるオブジェクトのハッシュ値は、Rubyの起動や実装ごとに同一であるとは限りません。Rubyの起動や実装の違いに影響されない安定したidが必要な場合は、ハッシュ値をカスタムメソッドで生成する必要があります。
これを修正するために、PostgreSQLデータベースにカスタムのhash_64()
関数を作成しました。
create function hash_64(_identifier character varying) returns bigint
language plpgsql
as
$$
DECLARE
hash bigint;
BEGIN
select left('x' || md5(_identifier), 16)::bit(64)::bigint into hash;
return hash;
END;
$$;
alter function hash_64(varchar) owner to dbuser;
続いて、このhash_64()
関数を用いてadvisory_lock
の実装を修正しました。
# frozen_string_literal: true
class ApplicationRecord < ActiveRecord::Base
self.abstract_class = true
def self.with_advisory_lock(*args)
transaction do
ApplicationRecord.connection.execute("SELECT pg_advisory_xact_lock(hash_64('#{args.join}'))")
yield
end
end
hash_64()
関数の実装は、Eventideのコードベースにあるものを利用しました。
ロックキーの算出をあくまでRubyでやりたいのであれば、他にもZlib#crc32
を使う方法などがあります。
# frozen_string_literal: true
class ApplicationRecord < ActiveRecord::Base
self.abstract_class = true
def self.with_advisory_lock(*args)
transaction do
ApplicationRecord.connection.execute("SELECT pg_advisory_xact_lock(#{Zlib.crc32(args.join)})")
yield
end
end
概要
元サイトの許諾を得て翻訳・公開いたします。
日本語タイトルは内容に即したものにしました。