Slide 1

Slide 1 text

SideKiqでジョブが二重起動した 事象を深堀りしました
 株式会社トイポ
 廿千 智紀(ハタチ トモノリ)


Slide 2

Slide 2 text

トイポの紹介


Slide 3

Slide 3 text

https://findy-code.io/companies/2086/jobs/B9yw9L141p8aA 
 ● [Done] Rails 6.0 -> 6.1 
 ● [Done] Rails 6.1 -> 7.0 ※今月 
 ● [Todo] Rails 7.0 -> 8.1 
 売上前年比200%で成長中! 
 ● 100万ユーザーを突破しました! 
 ● データ量が多くなってきました 
 ● 初期に開発したところ、データ量的に厳しさが 
 ● 拡張し辛いコードもときどき。 
 ● 積極的に片付けながら開発してます! 
 エンジニア募集中


Slide 4

Slide 4 text

自己紹介
 廿千 智紀(ハタチ トモノリ) @t_hatachi
 株式会社トイポ シニアエンジニア
 略歴
 ● 2006 - 2020 医薬業界SIer
 ● 2020 - 2023 SES
 ● 2023/09〜株式会社トイポ(=Ruby歴)
 Rails楽しいです。SQL好きです。
 趣味
 ● 釣り
 ● DIYなんでも(3Dプリンタ歴10年くらい)


Slide 5

Slide 5 text

最近になって急に2回発生しました。
 ● 2024/12/23 18:00
 ○ ChargeXXXXXJob
 ● 2025/02/09 00:07
 ○ PublishCouponXXXXXXXJob 
 
 SideKiqでジョブが二重起動してしまった
 直接お金に関わる
 クーポン増殖


Slide 6

Slide 6 text

最近になって急に2回発生しました。
 ● 2024/12/23 18:00
 ○ ChargeXXXXXJob
 ● 2025/02/09 00:07
 ○ PublishCouponXXXXXXXJob 
 背景
 ● 32種類の定期ジョブ
 ● 2台のtoypo-worker
 ○ ジョブキューにRedis。 
 ○ 同時にジョブをキューイングしたことで、 
 ジョブが重複して実行されてしまった模様。 
 ○ キューイングの起点までは(私は)追えませんでした。 
 SideKiqでジョブが二重起動してしまった
 直接お金に関わる
 クーポン増殖


Slide 7

Slide 7 text

SideKiqでジョブが二重起動してしまった
 最近になって急に2回発生しました。
 ● 2024/12/23 18:00
 ○ ChargeXXXXXJob
 ● 2025/02/09 00:07
 ○ PublishCouponXXXXXXXJob 
 背景
 ● 32種類の定期ジョブ
 ● 2台のtoypo-worker
 ○ ジョブキューにRedis。 
 ○ 同時にジョブをキューイングしたことで、 
 ジョブが重複して実行されてしまった模様。 
 ○ キューイングの起点までは(私は)追えませんでした。 
 直接お金に関わる
 クーポン増殖
 toypo-worker-1
 toypo-worker-2
 job queue
 queueing
 queueing


Slide 8

Slide 8 text

二重起動の原因を調べる
 # Removes a queued job instance # # @param [String] job_name The name of the job # @param [Time] time The time at which the job was cleared by the scheduler # # @return [Boolean] true if the job was registered, false otherwise def self.register_job_instance(job_name, time) job_key = pushed_job_key(job_name) registered, _ = Sidekiq.redis do |r| r.pipelined do r.zadd(job_key, time.to_i, time.to_i) r.expire(job_key, REGISTERED_JOBS_THRESHOLD_IN_SECONDS) end end registered end
 ruby/2.7.0/gems/sidekiq-scheduler-3.1.0/lib/sidekiq-scheduler/redis_manager.rb
 ジョブが重複しない制御があるのでは?
 ● Sidekiq-scheduler(gem)
 ○ Redisに一意なキーを作成(zadd) 
 ○ 成功したらキューイング 
 


Slide 9

Slide 9 text

ジョブが重複しない制御があるのでは?
 ● Sidekiq-scheduler(gem)
 ○ Redisに一意なキーを作成(zadd) 
 ○ 成功したらキューイング 
 ● Sidekiq(gem)
 ○ エンキュー
 ○ 成功したらRedisからキーを削除(zrem) 
 
 二重起動の原因を調べる
 def enqueue_jobs(now=Time.now.to_f.to_s, sorted_sets=SETS) Sidekiq.redis do |conn| sorted_sets.each do |sorted_set| while job = conn.zrangebyscore(sorted_set, '-inf', now, :limit => [0, 1]).first do if conn.zrem(sorted_set, job) Sidekiq::Client.push(Sidekiq.load_json(job)) Sidekiq::Logging.logger.debug { "enqueued #{sorted_set }: #{job}" } end end end end end ruby/2.7.0/gems/sidekiq-5.2.10/lib/sidekiq/scheduled.rb


Slide 10

Slide 10 text

ジョブが重複しない制御があるのでは?
 ● Sidekiq-scheduler(gem)
 ○ Redisに一意なキーを作成(zadd) 
 ○ 成功したらキューイング 
 ● Sidekiq(gem)
 ○ エンキュー
 ○ 成功したらRedisからキーを削除(zrem) 
 これらを踏まえると。
 ➔エンキュー後重複ジョブをキューイング可能。
  (ジョブの起動とか完了は関係なかった)
 二重起動の原因を調べる
 def enqueue_jobs(now=Time.now.to_f.to_s, sorted_sets=SETS) Sidekiq.redis do |conn| sorted_sets.each do |sorted_set| while job = conn.zrangebyscore(sorted_set, '-inf', now, :limit => [0, 1]).first do if conn.zrem(sorted_set, job) Sidekiq::Client.push(Sidekiq.load_json(job)) Sidekiq::Logging.logger.debug { "enqueued #{sorted_set }: #{job}" } end end end end end ruby/2.7.0/gems/sidekiq-5.2.10/lib/sidekiq/scheduled.rb


Slide 11

Slide 11 text

ジョブが重複しない制御があるのでは?
 ● Sidekiq-scheduler(gem)
 ○ Redisに一意なキーを作成(zadd) 
 ○ 成功したらキューイング 
 ● Sidekiq(gem)
 ○ エンキュー
 ○ 成功したらRedisからキーを削除(zrem) 
 これらを踏まえると。
 ➔エンキュー後重複ジョブをキューイング可能。
  (ジョブの起動とか完了は関係なかった)
 二重起動の原因を調べる
 toypo-worker-1
 toypo-worker-2
 job queue
 ②queueing
 ③enqueue
 ①zadd(key1)
 ④zrem
 ⑤zadd(key1) => true
 ⑥queueing


Slide 12

Slide 12 text

ジョブの二重起動を防ぐ
 エンキュー後も一定期間はジョブのキューイングをロックしたい。
 ➔activejob-uniquenessを使う。
 
 


Slide 13

Slide 13 text

ジョブの二重起動を防ぐ
 
 エンキュー後も一定期間はジョブのキューイングをロックしたい。
 ➔activejob-uniquenessを使う。
 二重起動したくないジョブに適用していく
 
 
 class UniqueApplicationJob < ApplicationJob unique :until_expired end app/jobs/unique_application_job.rb(NEW!) 
 config.lock_ttl = 1.minute config/initializers/active_job_uniqueness.rb(NEW!) 
 - class ChargexxxxxJob < ApplicationJob + class ChargexxxxxJob < UniqueApplicationJob queue_as :default app/jobs/charge_xxxxx_job.rb(もう二重起動したくない) 
 継承


Slide 14

Slide 14 text

ジョブの二重起動を防ぐ
 activejob-uniqueness
 ● キューイング後Redisに一意なキーを作成
 ○ sidekiq-schedulerがzaddするkeyとは別モノ 
 ○ キーが存在する間は同じジョブをロック 
 ○ 設定により期限は60秒間 
 ● ロックされた状態でジョブをキューイング
 するとエラー
 ActiveJob::Uniqueness::JobNotUnique: Not unique ChargexxxxxJob (Job ID: dc0af9b7-d849-4744-925e-658afea66494) (Lock key: activejob_uniqueness:charge_xxxxx_job:no_arguments) []

Slide 15

Slide 15 text

ジョブの二重起動を防ぐ
 activejob-uniqueness
 ● キューイング後Redisに一意なキーを作成
 ○ sidekiq-schedulerがzaddするkeyとは別モノ 
 ○ キーが存在する間は同じジョブをロック 
 ○ 設定により期限は60秒間 
 ● ロックされた状態でジョブをキューイング
 するとエラー
 toypo-worker-1
 toypo-worker-2
 job queue
 ②queueing
 + zadd(unique)
 ③enqueue
 ①zadd(key1)
 
 ④zrem(key1)
 (uniqueは残る)
 ⑤zadd(key1) => true
 ⑥queueing
 uniqueがある ので✕
 ActiveJob::Uniqueness::JobNotUnique: Not unique ChargexxxxxJob (Job ID: dc0af9b7-d849-4744-925e-658afea66494) (Lock key: activejob_uniqueness:charge_xxxxx_job:no_arguments) []

Slide 16

Slide 16 text

ジョブの二重起動を防ぐ
 他の案だったもの
 ● sidekiq enterprise → 💲223/mo
 ● ジョブの実行タイミングによらず処理の一貫性が確保されるようにプロダクトコード を修正する。 → 💲??? * 32ジョブ
 テスト
 ● 同じジョブを何度も実行する場合があった
 ● uniqueを無効にした
 ActiveJob::Uniqueness.test_mode! spec/rails_helper.rb 


Slide 17

Slide 17 text

まとめ
 ● Sidekiqでworker複数台構成だとジョブが二重起動する可能性があります。
 ○ トイポではこの対応を2/21にリリース後、再発していません。 
 ○ 今日の話がどこかで誰かの役に立てたら嬉しいです。 
 ○ トイポのSREはよいSREです。 
 
 ● 今回の対応で分かったことと、分からなかったこと。
 ○ Sidekiqのジョブのキューイングの挙動がよく理解できました。 
 ○ worker2台のキューイングのタイミングはどうなっている…? 
 ○ 機会があったらgemのコードを追いかけてみます。