copy_tuner_client 2.2.1 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6a9a2fe07a403679fa3bf6d10c72c13fadcd31536af653a79b3e6d84e80c10ab
4
- data.tar.gz: b3300594e38799ec7004f4c4fb0e35b051a447c6dd8b66ffb512bb59d7b851b2
3
+ metadata.gz: 3b141908e7fa8f6702cf9fccc712b88440af9aa96cc25c8b7875b3269d199825
4
+ data.tar.gz: c7b3f527ba59c387f721364fe0e2689fd849b99b33f4e53557611f23af828794
5
5
  SHA512:
6
- metadata.gz: 5af9d06699d0361e360ea31dd4bb67ba04d2e711e0324ee9d85ee59914404ef905e173e7b6fd6ef81ede67773784fd4788ccb45fac7399ce57ad1b67d6d094ce
7
- data.tar.gz: 2143f94b230219cae83f8d317f52ba338b282894178edce8129eac5382be76836209fbb29b8dd9e25ac96d3a91a6b93fa326fed4c426f9b2304b9b12d9233482
6
+ metadata.gz: cae2e31ecad418c08b1143f57bd5acf83b574ae5ca4aac1526e594ce911e7d37c98b7098a5a4d1bcaa4cacc133368764712525d80ef63b7dac4390bed5858093
7
+ data.tar.gz: 06e3b11bb99dc6162a0a1110999e1fe2ab4bc3dde42cecfa8ecbe0656a0861b7ea17f659c0ef1791f4df1601cd10e7b465eac755d2ae5305d22e78e0ffe260c4
data/CLAUDE.md CHANGED
@@ -34,6 +34,16 @@ Rails 統合は engine.rb のイニシャライザ経由(ヘルパー/SimpleFo
34
34
  (vite.config.ts が `src/main.ts` → `app/assets/javascripts/copytuner.js` を出力)。
35
35
  - `local_first_key_regexp` — locale を除いたキー対象・lookup 時に作用(ローカル YAML 優先)。
36
36
  local_first キーのアップロード抑止は `Cache#[]=` に集約されている。
37
+ - **poller スレッドの fork 対応は `ForkHook`(`Process._fork` に prepend)に集約する**
38
+ (fork の直前に `Poller#stop` で協調的に停止し、fork 後に親子の両方で張り直す。理由: fork 後の子に残る
39
+ Thread オブジェクトの見え方(`alive?` / `join` の結果)はドキュメント化されていない CRuby の実装依存なので、
40
+ pid を記録して差分を見るような後始末には寄せない。子でも張り直すので、Puma の `fork_worker` のように
41
+ worker が worker を fork する構成でもサーバ固有のフックなしで poller が立つ。
42
+ アプリケーションサーバごとのフック(`ProcessGuard#register_*_hook`)を増やす前にここで足りるか確認する)。
43
+ 起動方法・モードごとにどのプロセスで poller が起動するかは `docs/poller-startup.md` に実測結果がある。
44
+ - **`Poller#poll` は例外をスレッドの外へ漏らさない**
45
+ (`Poller#stop` は fork 経路から呼ばれ、`Thread#join` はスレッドの例外を再送出するため、漏らすと
46
+ poller の失敗がアプリ側の `fork` を壊す)。
37
47
  - **アップロード抑止の新ルールは `Cache#[]=` に足す。`I18nBackend` の書き込み経路(`lookup` / `default` / `store_item`)ごとに個別ガードを足さない**
38
48
  (理由: cache への書き込みは全経路が最終的に `Cache#[]=` を通る単一の関門。経路ごとにガードを足すと付け忘れの穴が生まれ、同じチェックが分散して保守負担になる。実際 local_first の抑止は当初 `default` 個別に足したが穴が残り、`Cache#[]=` への集約に作り直した)。
39
49
 
@@ -0,0 +1,108 @@
1
+ # poller がどのプロセスで起動するか
2
+
3
+ `Poller` はバックグラウンドスレッドで CopyTuner サーバと同期する。**リクエストを処理するプロセスで
4
+ このスレッドが動いていないと翻訳が更新されない**が、動いていなくてもエラーにはならず、翻訳が古いまま
5
+ になるだけなので気づきにくい。アプリケーションサーバの起動方法とモードによって「どのプロセスがアプリを
6
+ ロードするか」が変わるため、ここに実測結果を残す。
7
+
8
+ Puma 以外(Unicorn / Passenger / delayed_job / good_job)については `ProcessGuard` のフック登録
9
+ メソッドを参照。
10
+
11
+ ## 前提: アプリをロードしたプロセスで initializer が走る
12
+
13
+ `CopyTunerClient.configure`(= `Configuration#apply` = `ProcessGuard#start`)は、Rails の
14
+ initializer として **アプリをロードしたプロセスで 1 回だけ**走る。したがって「どのプロセスがアプリを
15
+ ロードするか」が起点になる。
16
+
17
+ ## 実測結果
18
+
19
+ 検証環境: puma 8.0.2 / Rails 8.1.3.1 / Ruby 4.0.2(2026-09-03 実測)
20
+
21
+ | 起動方法 | モード | アプリをロードするプロセス | poller の起動経路 | master に poller |
22
+ | --- | --- | --- | --- | --- |
23
+ | `rails server` | single | そのプロセス | 非 spawner 経路(`start_polling`) | (単一プロセス) |
24
+ | `rails server` | cluster(preload の有無を問わず) | **master のみ** | master で `start_polling` → `ForkHook` が fork 後に worker で張り直す | 立つ |
25
+ | `puma -C` | single | そのプロセス(`Runner#load_and_bind`) | `Puma::Runner#start_server` への prepend | (単一プロセス) |
26
+ | `puma -C` | cluster + preload | master のみ | 各 worker の `start_server` で prepend したフックが発火 | 立たない |
27
+ | `puma -C` | cluster + preload なし | **各 worker** | `$0` の `'cluster worker'` 判定による分岐 | 立たない(master はアプリをロードしない) |
28
+
29
+ ### 判定に使っている値
30
+
31
+ `ProcessGuard#puma_spawner?` は `defined?(Puma::Runner) && $PROGRAM_NAME.include?('puma')` で判定する。
32
+ 実測値は次のとおり。
33
+
34
+ | 起動方法 | `$PROGRAM_NAME` | `defined?(Puma)` | `defined?(Puma::Runner)` | `puma_spawner?` |
35
+ | --- | --- | --- | --- | --- |
36
+ | `rails server` | `"bin/rails"` | yes | **no** | false |
37
+ | `puma -C`(master / single) | `".../bin/puma"` | yes | yes | true |
38
+ | `puma -C`(preload なしの worker) | `"puma: cluster worker 0: <master pid> [app]"` | yes | yes | true |
39
+
40
+ `$PROGRAM_NAME` が master と worker で違うのは Puma 側の実装差による。master は
41
+ `launcher.rb` の `Process.setproctitle`(`$0` を変えない)、worker は `cluster/worker.rb` の
42
+ `$0 = title`(変える)を使っている。
43
+
44
+ ## 各行の背景
45
+
46
+ ### `rails server` は preload 設定に関係なく master でアプリをロードする
47
+
48
+ Rails は Puma を起動する**前に** Rack アプリを組み立てて(`Rails::Server#log_to_stdout` →
49
+ `wrapped_app`)、オブジェクトとして Puma に渡す。そのため `preload_app!` を明示的に切っても worker が
50
+ アプリをロードし直すことはなく、必ず master でロードされる。
51
+
52
+ この構成では `Puma::Runner` が initializer の時点でまだ未定義なので `puma_spawner?` は false になり、
53
+ master で poller が起動する。fork 後の worker には `ForkHook`(`Process._fork` に prepend)が
54
+ 引き継ぐ。master にも poller が 1 本残るが、`puma_spawner?` を無理に真にしようとすると起動方法ごとの
55
+ 判定を増やすことになるため、余分な 1 本を許容している。
56
+
57
+ ### Puma 8 は `workers > 1` のとき preload が既定で有効
58
+
59
+ `Puma::Configuration#set_conditional_default_options` が `preload_app` の既定値を
60
+ `!prune_bundler && workers > 1 && Puma.forkable?` で決めている。非 preload を試すには
61
+ `preload_app!(false)` を明示する必要がある。
62
+
63
+ ### 非 preload では `start_server` の中でアプリがロードされる
64
+
65
+ `Runner#start_server` は `Puma::Server.new(app, ...)` を呼び、`Runner#app` が
66
+ `@app ||= @config.app` で遅延ロードする。つまり非 preload の worker では、initializer が走る時点で
67
+ 既に `start_server` が実行中であり、**そこで `Puma::Runner` に prepend しても間に合わない**。
68
+
69
+ このため `$0` の `'cluster worker'` 判定は最適化ではなく、この構成で poller を起動する唯一の経路になっている。
70
+
71
+ ## 表のとおりにならない例外
72
+
73
+ 上の表は「poller のスレッドが起動する経路」であって、起動した poller が動き続けることまでは
74
+ 保証しない。`Poller` はスレッドの生死とは別にライフサイクルの意図を持っており、次の場合は
75
+ 表の経路を通っても poller が居なくなる。
76
+
77
+ - **`InvalidApiKey`** — API キーが不正だと `poll` が自ら終了し、以後は fork をまたいでも張り直さない
78
+ (張り直しても同じ理由で死ぬだけなので)。ログに `Invalid API key` が出る
79
+ - **起動時に CopyTuner サーバへ到達できない** — `Configuration#apply` の `cache.download` が
80
+ `ConnectionError` を再送出するため、そもそもプロセスが起動に失敗する。Puma の worker では
81
+ `! Unable to start worker` になる
82
+
83
+ 再検証の際は、まずこの 2 つに当たっていないかをログで確かめる。
84
+
85
+ ## 再検証のしかた
86
+
87
+ Puma や Rails を上げたときにこの表が変わっていないか確かめるには、次の観測点を見るのが早い。
88
+
89
+ 1. 最小の Rails アプリを用意し、`config/initializers` で `Process.pid` / `Process.ppid` /
90
+ `$PROGRAM_NAME` / `defined?(Puma::Runner)` を出力する。これで**どのプロセスがアプリをロードしたか**が分かる
91
+ 2. 任意のエンドポイントで `CopyTunerClient.poller` の `@thread` を覗き、`alive?` を返す。
92
+ fork 後の worker が親から継承した dead な Thread を持っていると、ここが `false` になる
93
+ 3. 翻訳を返すだけの偽 CopyTuner サーバを立て(`draft_blurbs.json` を返し、`draft_blurbs` /
94
+ `deploys` の POST を受ける)、`polling_delay` を数秒にする。起動後にサーバ側の翻訳を書き換え、
95
+ worker が拾うかどうかで実際の同期を確認する
96
+ 4. 上の表の 5 構成(`rails server` / `puma -C` × single / cluster+preload / cluster+非 preload)で回す
97
+
98
+ `ProcessGuard` のログ(`Register Puma fork hook` / `Puma would be clustered mode without preload_app` /
99
+ `start poller thread`)をプロセス ID 付きで集計すると、どの経路を通ったかが分かる。
100
+
101
+ ## 関連
102
+
103
+ - `CopyTunerClient::ForkHook` — fork をまたいで poller を引き継ぐ。判定が外れて master で poller が
104
+ 起動してしまった場合の安全網でもある
105
+ - `CopyTunerClient::Poller` — スレッドの生死とは別に `@running`(ポーリングを継続する意図)と
106
+ `@aborted`(張り直しても同じ理由で死ぬ終わり方をしたか)を持つ。`ForkHook` が fork 後に張り直すか
107
+ どうかは `Poller#stop` の戻り値、すなわちこの意図で決まる(スレッドが生きているかではない)
108
+ - [Ruby における fork と Thread の挙動の調査](https://gist.github.com/shunichi/c236b6a85a46a16c60262047ca299608)
@@ -0,0 +1,53 @@
1
+ module CopyTunerClient
2
+ # fork をまたいで poller スレッドを引き継ぐためのフック。
3
+ # Process._fork に prepend し、fork の直前に poller を協調的に停止して、
4
+ # fork のあと親子それぞれで張り直す。
5
+ module ForkHook
6
+ # Module#prepend は同じモジュールを二重に挿さないので、呼び出し側で登録済みかを見なくてよい
7
+ def self.install
8
+ ::Process.singleton_class.prepend(self)
9
+ end
10
+
11
+ def _fork
12
+ # fork はアプリの任意のタイミングで起きる。Process._fork への prepend は外せないので、
13
+ # configure 前や configuration 差し替え中に NoMethodError でアプリの fork を
14
+ # 壊さないよう nil を許容する
15
+ poller = CopyTunerClient.configuration&.poller
16
+ # スレッドは子プロセスに引き継がれない。fork 後に子へ残る Thread オブジェクトの見え方
17
+ # (alive? / join の結果)はドキュメント化されていない CRuby の実装依存なので、
18
+ # そこに依存した後始末はせず、fork 前に協調的に停止しておく。
19
+ # ここは rescue しない。停止の失敗を握り潰すと、動いている poller ごと fork することになり
20
+ # このフックの目的そのものを裏切る
21
+ restart = poller ? poller.stop : false
22
+
23
+ begin
24
+ super
25
+ ensure
26
+ # ensure は親(super の戻り値 = 子の pid)と子(同 0)の両方を通り、super が失敗した
27
+ # ときも親で復元する。「fork 前に動いていたプロセスは fork 後も動いている」状態を保つ。
28
+ # 子でも張り直すのは、Puma の fork_worker のように worker が worker を fork する
29
+ # 構成でもサーバ固有のフックに頼らず poller を立てるため
30
+ ForkHook.restart_poller(poller) if restart
31
+ end
32
+ end
33
+
34
+ # Process.singleton_class に prepend されるため、インスタンスメソッドとして定義すると
35
+ # Process 自身にメソッドが生えてしまう。フックの補助はモジュール側の特異メソッドに置く
36
+ #
37
+ # fork 後に例外を漏らすと「子プロセスは生成済みなのに親の fork が例外を投げる」状態になり、
38
+ # 呼び出し側が子の pid を受け取れなくなる。起動の失敗はログに落として fork は成立させる
39
+ def self.restart_poller(poller)
40
+ poller.start
41
+ rescue StandardError => e
42
+ log_error("CopyTuner: fork 後の poller 起動に失敗しました: #{e.class}: #{e.message}")
43
+ end
44
+
45
+ # ログ出力自体が失敗しても fork は成立させる。ここで例外を漏らすと、失敗を握るために
46
+ # 置いた rescue が逆に fork を壊す
47
+ def self.log_error(message)
48
+ CopyTunerClient.configuration&.logger&.error(message)
49
+ rescue StandardError
50
+ nil
51
+ end
52
+ end
53
+ end
@@ -9,28 +9,60 @@ module CopyTunerClient
9
9
  # @option options [Logger] :logger where errors should be logged
10
10
  # @option options [Fixnum] :polling_delay how long to wait in between requests
11
11
  def initialize(cache, options)
12
- @cache = cache
13
- @polling_delay = options[:polling_delay]
14
- @logger = options[:logger]
15
- @command_queue = CopyTunerClient::QueueWithTimeout.new
16
- @mutex = Mutex.new
17
- @thread = nil
12
+ @cache = cache
13
+ @polling_delay = options[:polling_delay]
14
+ @logger = options[:logger]
15
+ @command_queue = CopyTunerClient::QueueWithTimeout.new
16
+ @mutex = Mutex.new
17
+ @thread = nil
18
+ @last_synced_at = nil
19
+ # スレッドの生死とは別に、ライフサイクルの意図を持つ。
20
+ # @running: ポーリングを継続する意図があるか(start で true / stop で false)
21
+ # @aborted: 張り直しても同じ理由で死ぬと分かっている終わり方をしたか
22
+ @running = false
23
+ @aborted = false
18
24
  end
19
25
 
20
26
  def start
21
27
  @mutex.synchronize do
22
- if @thread.nil?
23
- @logger.info 'start poller thread'
24
- @thread = Thread.new { poll } or logger.error("Couldn't start poller thread")
25
- end
28
+ @running = true
29
+ # 回復の見込みがない理由で終了しているなら張り直さない。fork のたびに同じ例外で
30
+ # 死ぬスレッドを作り直してログを埋めるだけになる
31
+ next if @aborted
32
+ # fork 後の子は親から dead な Thread オブジェクトを継承するため、nil かどうかだけでは
33
+ # 「動いていない」を判定できない。死んでいるスレッドは張り直す
34
+ next if @thread&.alive?
35
+
36
+ # コマンドキューは世代ごとに作り直し、スレッドに自分のキューを渡す。前の世代宛に
37
+ # 積まれたまま未消費で残った :stop を次の世代が 1 周目で拾って自殺するのを、
38
+ # 「1 つのキューは 1 本のスレッドだけのもの」という不変条件で構造的に防ぐ
39
+ queue = CopyTunerClient::QueueWithTimeout.new
40
+ @command_queue = queue
41
+ @logger.info 'start poller thread'
42
+ @thread = Thread.new { poll(queue) } or logger.error("Couldn't start poller thread")
26
43
  end
27
44
  end
28
45
 
46
+ # 戻り値は ForkHook が「fork 後に張り直すか」を決めるのに使う。スレッドの生死ではなく
47
+ # ポーリングを継続する意図があったかを返す。想定外の例外で死んだだけのスレッドは
48
+ # fork 後に張り直したいが、生死で判定すると張り直せなくなる
49
+ #
50
+ # @return [Boolean] ポーリング継続の意図があった(= fork 後に張り直すべき)なら +true+
29
51
  def stop
30
52
  @mutex.synchronize do
31
- @command_queue.uniq_push(:stop)
32
- @thread&.join
53
+ resumable = @running && !@aborted
54
+ @running = false
55
+
56
+ thread = @thread
33
57
  @thread = nil
58
+ # 例外で終わったスレッドは非 nil のまま dead で残る。それに :stop を積んでも誰も
59
+ # pop しないので、生きているときだけ積んで待つ
60
+ if thread&.alive?
61
+ @command_queue.uniq_push(:stop)
62
+ thread.join
63
+ end
64
+
65
+ resumable
34
66
  end
35
67
  end
36
68
 
@@ -46,20 +78,52 @@ module CopyTunerClient
46
78
 
47
79
  attr_reader :cache, :logger, :polling_delay
48
80
 
49
- def poll
50
- loop do
51
- cache.sync
52
- logger.flush if logger.respond_to?(:flush)
53
- begin
54
- command = @command_queue.pop_with_timeout(polling_delay)
55
- break if command == :stop
56
- rescue ThreadError
57
- # timeout
58
- end
81
+ def poll(queue)
82
+ timeout = remaining_delay
83
+ until wait_for_command(queue, timeout) == :stop
84
+ sync
85
+ timeout = polling_delay
59
86
  end
60
- @logger.info 'stop poller thread'
87
+ logger.info 'stop poller thread'
61
88
  rescue InvalidApiKey => e
89
+ # キーが不正なら張り直しても同じ結果になるので、以後の再開を止める
90
+ @aborted = true
62
91
  logger.error(e.message)
92
+ rescue StandardError => e
93
+ # 例外はスレッドの外へ漏らさない。stop の join は fork の直前にも呼ばれるため、
94
+ # 漏らすと poller の失敗がアプリ側の fork まで巻き添えにする。
95
+ # ここで握ると report_on_exception による stderr 出力も消えるので backtrace を残す
96
+ logger.error("poller thread aborted: #{e.class}: #{e.message}\n#{e.backtrace&.first(5)&.join("\n")}")
97
+ end
98
+
99
+ def sync
100
+ cache.sync
101
+ @last_synced_at = monotonic_now
102
+ logger.flush if logger.respond_to?(:flush)
103
+ end
104
+
105
+ # 最初の待ち時間を「前回 sync からの残り」にすることで、stop / start を繰り返しても
106
+ # sync の間隔を保つ。fork のたびに stop / start されるので、これが無いと worker を
107
+ # fork する数だけ親プロセスで sync(HTTP 往復)が走り直し、その分 join も待たされる。
108
+ # 基点には NTP や手動の時刻変更で巻き戻らない CLOCK_MONOTONIC を使う
109
+ # (Time.now だと時計が後ろに飛んだぶんだけ待ち時間が伸びて同期が止まる)
110
+ def remaining_delay
111
+ return 0 if @last_synced_at.nil?
112
+
113
+ remaining = polling_delay - (monotonic_now - @last_synced_at)
114
+ remaining.negative? ? 0 : remaining
115
+ end
116
+
117
+ # 自分の世代のキューを受け取る。@command_queue は start のたびに差し替わるため、
118
+ # 終了処理中の古いスレッドが新しい世代のキューを覗いてしまわないようにする
119
+ def wait_for_command(queue, timeout)
120
+ queue.pop_with_timeout(timeout)
121
+ rescue ThreadError
122
+ nil # timeout
123
+ end
124
+
125
+ def monotonic_now
126
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
63
127
  end
64
128
  end
65
129
  end
@@ -1,3 +1,5 @@
1
+ require 'copy_tuner_client/fork_hook'
2
+
1
3
  module CopyTunerClient
2
4
  # Starts the poller from a worker process, or register hooks for a spawner
3
5
  # process (such as in Unicorn or Passenger). Also registers hooks for exiting
@@ -14,6 +16,9 @@ module CopyTunerClient
14
16
 
15
17
  # Starts the poller or registers hooks
16
18
  def start
19
+ # fork の前後で poller を張り直すフックは、どのプロセスでも必ず登録する
20
+ ForkHook.install
21
+
17
22
  if spawner?
18
23
  register_spawn_hooks
19
24
  else
@@ -115,9 +120,12 @@ module CopyTunerClient
115
120
  ::Process.singleton_class.prepend(hook_module)
116
121
  end
117
122
 
123
+ # 起動方法(rails server / puma -C)とモード(single / cluster / preload の有無)ごとに
124
+ # どのプロセスで poller が起動するかは docs/poller-startup.md にまとめてある
118
125
  def register_puma_hook
119
- # If Puma is clustered mode without preload_app, this method is called on worker process.
120
- # Just start poller and return.
126
+ # preload cluster worker はここに来る。この構成ではアプリのロードが
127
+ # Puma::Runner#start_server の中で起きるため、今から prepend しても実行中の呼び出しには
128
+ # 間に合わない。つまりこの分岐が poller を起動する唯一の経路で、最適化ではなく必須
121
129
  if $PROGRAM_NAME.include?('cluster worker')
122
130
  @logger.info('Puma would be clustered mode without preload_app')
123
131
  @poller.start
@@ -125,8 +133,9 @@ module CopyTunerClient
125
133
  end
126
134
 
127
135
  @logger.info('Register Puma fork hook')
128
- # If Puma is clustered mode with preload_app, this method is called before fork.
129
- # Delay poller start until Puma::Runner#start_server which is called on worker process.
136
+ # preload ありの cluster ではこのメソッドが fork 前の master で走る。master
137
+ # リクエストを捌かないのでここでは起動せず、worker 側で呼ばれる
138
+ # Puma::Runner#start_server まで遅らせる
130
139
  poller = @poller
131
140
  hook_module =
132
141
  Module.new do
@@ -51,10 +51,16 @@ module CopyTunerClient
51
51
 
52
52
  def wait_with_timeout(timeout)
53
53
  # wait for element or timeout
54
- timeout_time = timeout + Time.now.to_f
55
- while @queue.empty? && (remaining_time = timeout_time - Time.now.to_f).positive?
54
+ # NTP step 補正や手動の時刻変更で巻き戻らない CLOCK_MONOTONIC で締め切りを測る。
55
+ # ウォールクロックだと時計が後ろへ飛んだぶんだけ待ち時間が伸びる
56
+ deadline = monotonic_now + timeout
57
+ while @queue.empty? && (remaining_time = deadline - monotonic_now).positive?
56
58
  @received.wait(@mutex, remaining_time)
57
59
  end
58
60
  end
61
+
62
+ def monotonic_now
63
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
64
+ end
59
65
  end
60
66
  end
@@ -1,6 +1,6 @@
1
1
  module CopyTunerClient
2
2
  # Client version
3
- VERSION = '2.2.1'.freeze
3
+ VERSION = '2.3.0'.freeze
4
4
 
5
5
  # API version being used to communicate with the server
6
6
  API_VERSION = '2.0'.freeze
@@ -0,0 +1,171 @@
1
+ require 'spec_helper'
2
+
3
+ describe CopyTunerClient::ForkHook do
4
+ let(:client) { FakeClient.new }
5
+ let(:cache) { CopyTunerClient::Cache.new(client, logger: FakeLogger.new) }
6
+ let(:poller) do
7
+ config = CopyTunerClient::Configuration.new.to_hash
8
+ CopyTunerClient::Poller.new(cache, config.update(logger: FakeLogger.new, polling_delay:))
9
+ end
10
+
11
+ def polling_delay
12
+ 0.5
13
+ end
14
+
15
+ before do
16
+ described_class.install
17
+ # フックは CopyTunerClient.configuration&.poller を見るので、実際の設定に載せる
18
+ CopyTunerClient.configuration.poller = poller
19
+ end
20
+
21
+ after do
22
+ poller.stop
23
+ end
24
+
25
+ describe '停止と再開の順序' do
26
+ # 実際に fork してしまうと「fork の瞬間にスレッドが止まっているか」を観測できないため、
27
+ # _fork の代わりに記録だけするオブジェクトに prepend して順序を見る
28
+ def build_forkable(events, &fork_body)
29
+ forkable = Object.new
30
+ forkable.define_singleton_method(:_fork) do
31
+ events << :fork
32
+ fork_body ? fork_body.call : 0
33
+ end
34
+ forkable.singleton_class.prepend(described_class)
35
+ forkable
36
+ end
37
+
38
+ def stub_poller(events, stopped:)
39
+ allow(poller).to receive(:stop) do
40
+ events << :stop
41
+ stopped
42
+ end
43
+ allow(poller).to receive(:start) { events << :start }
44
+ end
45
+
46
+ it 'fork の前に poller を停止し、fork の後に再開する' do
47
+ events = []
48
+ stub_poller(events, stopped: true)
49
+
50
+ build_forkable(events)._fork
51
+
52
+ expect(events).to eq(%i[stop fork start])
53
+ end
54
+
55
+ it 'fork 前に poller が動いていなければ再開しない' do
56
+ events = []
57
+ stub_poller(events, stopped: false)
58
+
59
+ build_forkable(events)._fork
60
+
61
+ expect(events).to eq(%i[stop fork])
62
+ end
63
+
64
+ it 'fork が失敗しても poller を再開する' do
65
+ events = []
66
+ stub_poller(events, stopped: true)
67
+
68
+ forkable = build_forkable(events) { raise Errno::EAGAIN }
69
+
70
+ expect { forkable._fork }.to raise_error(Errno::EAGAIN)
71
+ expect(events).to eq(%i[stop fork start])
72
+ end
73
+ end
74
+
75
+ describe 'poller を取得できないとき' do
76
+ it 'configuration が nil でも fork を壊さない' do
77
+ # Process._fork への prepend は外せないので、ここで例外を漏らすとアプリの
78
+ # すべての fork が失敗する
79
+ CopyTunerClient.configuration = nil
80
+
81
+ expect { Process.waitpid(fork { exit!(0) }) }.not_to raise_error
82
+ end
83
+
84
+ it 'ログ出力自体が失敗しても fork は成立する' do
85
+ allow(poller).to receive(:stop).and_return(true)
86
+ allow(poller).to receive(:start).and_raise(ThreadError, 'cannot create thread')
87
+ allow(CopyTunerClient.configuration.logger).to receive(:error).and_raise('logger is broken')
88
+
89
+ expect { Process.waitpid(fork { exit!(0) }) }.not_to raise_error
90
+ end
91
+
92
+ it 'fork 後の poller 起動に失敗しても fork 自体は成立する' do
93
+ allow(poller).to receive(:stop).and_return(true)
94
+ allow(poller).to receive(:start).and_raise(ThreadError, 'cannot create thread')
95
+
96
+ expect { Process.waitpid(fork { exit!(0) }) }.not_to raise_error
97
+ end
98
+ end
99
+
100
+ describe '実際に fork したとき' do
101
+ it 'fork 前に poller が例外で死んでいても親子とも張り直す' do
102
+ # rails server の cluster では ForkHook が唯一の再開経路なので、ここで張り直さないと
103
+ # 親子とも poller を失う
104
+ fail_once = true
105
+ allow(cache).to receive(:sync).and_wrap_original do |original, *args|
106
+ if fail_once
107
+ fail_once = false
108
+ raise 'boom'
109
+ end
110
+ original.call(*args)
111
+ end
112
+
113
+ poller.start
114
+ sleep(polling_delay * 0.4) # スレッドが例外で終わる
115
+
116
+ reader, writer = IO.pipe
117
+ pid =
118
+ fork do
119
+ reader.close
120
+ client['child.key'] = 'value'
121
+ sleep(polling_delay * 3)
122
+ writer.write(cache['child.key'].to_s)
123
+ writer.close
124
+ exit!(0)
125
+ end
126
+
127
+ writer.close
128
+ child_result = reader.read
129
+ Process.waitpid(pid)
130
+
131
+ client['parent.key'] = 'value'
132
+ sleep(polling_delay * 3)
133
+
134
+ expect(child_result).to eq('value')
135
+ expect(cache['parent.key']).to eq('value')
136
+ end
137
+
138
+ it '子プロセスでも poller がポーリングを続ける' do
139
+ poller.start
140
+ reader, writer = IO.pipe
141
+
142
+ pid =
143
+ fork do
144
+ reader.close
145
+ client['test.key'] = 'value'
146
+ sleep(polling_delay * 3)
147
+ writer.write(cache['test.key'].to_s)
148
+ writer.close
149
+ exit!(0)
150
+ end
151
+
152
+ writer.close
153
+ result = reader.read
154
+ Process.waitpid(pid)
155
+
156
+ expect(result).to eq('value')
157
+ end
158
+
159
+ it '親プロセスでも poller がポーリングを続ける' do
160
+ poller.start
161
+ sleep(polling_delay * 0.2)
162
+
163
+ Process.waitpid(fork { exit!(0) })
164
+
165
+ client['test.key'] = 'value'
166
+ sleep(polling_delay * 3)
167
+
168
+ expect(cache['test.key']).to eq('value')
169
+ end
170
+ end
171
+ end
@@ -11,7 +11,7 @@ describe CopyTunerClient::Poller do
11
11
 
12
12
  def build_poller(config = {})
13
13
  config[:logger] ||= FakeLogger.new
14
- config[:polling_delay] = polling_delay
14
+ config[:polling_delay] ||= polling_delay
15
15
  default_config = CopyTunerClient::Configuration.new.to_hash
16
16
  poller = CopyTunerClient::Poller.new(cache, default_config.update(config))
17
17
  pollers << poller
@@ -95,4 +95,151 @@ describe CopyTunerClient::Poller do
95
95
 
96
96
  expect(logger).to have_received(:flush).at_least(:once)
97
97
  end
98
+
99
+ describe '#stop' do
100
+ it 'スレッドを停止したときは true を返す' do
101
+ poller = build_poller
102
+ poller.start
103
+
104
+ expect(poller.stop).to be true
105
+ end
106
+
107
+ it 'スレッドが動いていないときは false を返す' do
108
+ poller = build_poller
109
+
110
+ expect(poller.stop).to be false
111
+ end
112
+
113
+ it 'スレッドが例外で終わっていても例外を再送出しない' do
114
+ # stop は fork の直前にも呼ばれる。ここで join が poller の例外を再送出すると
115
+ # アプリ側の fork まで巻き添えになる
116
+ logger = FakeLogger.new
117
+ allow(cache).to receive(:sync).and_raise('boom')
118
+ poller = build_poller(logger:)
119
+ poller.start
120
+ sleep(polling_delay * 0.2)
121
+
122
+ expect { poller.stop }.not_to raise_error
123
+ expect(logger).to have_entry(:error, 'boom')
124
+ end
125
+
126
+ it 'start していないときに stop してもキューに :stop を残さない' do
127
+ poller = build_poller
128
+ poller.stop
129
+
130
+ poller.start
131
+
132
+ # 1 周目の sync だけでは「:stop がキューに残っている」状態と区別できないため、
133
+ # 2 周目以降も同期が続くことを確かめる
134
+ wait_for_next_sync
135
+ client['test.key'] = 'value'
136
+ wait_for_next_sync
137
+
138
+ expect(cache['test.key']).to eq('value')
139
+ end
140
+ end
141
+
142
+ describe '世代をまたいだコマンドの混入' do
143
+ # :stop は pop されるまでキューに残る。前の世代のスレッド宛に積まれた :stop を
144
+ # 次の世代のスレッドが拾うと、起動直後に自分を止めてしまう
145
+ it 'スレッドが例外で死んだ後に stop → start してもポーリングが続く' do
146
+ poller = build_poller
147
+ allow(cache).to receive(:sync).and_raise('boom')
148
+ poller.start
149
+ sleep(polling_delay * 0.3) # スレッドが例外で終わるのを待つ
150
+
151
+ allow(cache).to receive(:sync).and_call_original
152
+ poller.stop
153
+ poller.start
154
+
155
+ wait_for_next_sync
156
+ client['test.key'] = 'value'
157
+ wait_for_next_sync
158
+
159
+ expect(cache['test.key']).to eq('value')
160
+ end
161
+
162
+ it 'stop の直後に sync が例外で終わっても、次の start でポーリングが続く' do
163
+ poller = build_poller
164
+ # stop が :stop を積んだ後にスレッドが例外で終わる順序を作る。
165
+ # sync に入ったところで stop を待たせ、:stop を積み終えてから例外にする
166
+ syncing = Queue.new
167
+ resume = Queue.new
168
+ allow(cache).to receive(:sync) do
169
+ syncing << true
170
+ resume.pop
171
+ raise 'boom'
172
+ end
173
+
174
+ poller.start
175
+ syncing.pop # スレッドが sync に入った
176
+
177
+ stopper = Thread.new { poller.stop }
178
+ sleep(0.1) # :stop がキューに積まれるのを待つ
179
+ resume << true # ここで sync が例外になり、:stop は消費されない
180
+ stopper.join
181
+
182
+ allow(cache).to receive(:sync).and_call_original
183
+ poller.start
184
+
185
+ wait_for_next_sync
186
+ client['test.key'] = 'value'
187
+ wait_for_next_sync
188
+
189
+ expect(cache['test.key']).to eq('value')
190
+ end
191
+
192
+ # stop の戻り値は ForkHook の「fork 後に張り直すか」の判断に使われる。スレッドの生死ではなく
193
+ # 「ポーリングを継続する意図があるか」を返す必要がある
194
+ it 'スレッドが例外で死んでいても、起動中だったなら stop は true を返す' do
195
+ poller = build_poller
196
+ allow(cache).to receive(:sync).and_raise('boom')
197
+ poller.start
198
+ sleep(polling_delay * 0.3)
199
+
200
+ expect(poller.stop).to be true
201
+ end
202
+
203
+ it '回復の見込みがない理由で終了した後の stop は false を返す' do
204
+ poller = build_poller
205
+ allow(cache).to receive(:sync).and_raise(CopyTunerClient::InvalidApiKey, 'Invalid API key')
206
+ poller.start
207
+ sleep(polling_delay * 0.3)
208
+
209
+ # API キーが不正なら fork のたびに張り直しても同じ理由で死ぬだけなので再開させない
210
+ expect(poller.stop).to be false
211
+ end
212
+ end
213
+
214
+ describe '再開時の sync 間隔' do
215
+ # 経過時間で判定するため、CI の負荷や GC で sleep が伸びても落ちないよう
216
+ # 他のテストより長い間隔を使ってマージンを稼ぐ
217
+ let(:slow_delay) { 2.0 }
218
+
219
+ it '初回の start では待たずに sync する' do
220
+ poller = build_poller(polling_delay: slow_delay)
221
+
222
+ poller.start
223
+ sleep(slow_delay * 0.2)
224
+
225
+ expect(client.downloads).to eq(1)
226
+ end
227
+
228
+ it '停止直後に再開したときは前回 sync からの残り時間を待ってから sync する' do
229
+ poller = build_poller(polling_delay: slow_delay)
230
+ poller.start
231
+ sleep(slow_delay * 0.2)
232
+ poller.stop
233
+
234
+ poller.start
235
+
236
+ # 前回 sync から polling_delay 経つまでは sync しない
237
+ sleep(slow_delay * 0.4)
238
+ expect(client.downloads).to eq(1)
239
+
240
+ # 残り時間が過ぎれば sync が再開する
241
+ sleep(slow_delay * 0.8)
242
+ expect(client.downloads).to eq(2)
243
+ end
244
+ end
98
245
  end
@@ -20,6 +20,14 @@ describe CopyTunerClient::ProcessGuard do
20
20
  process_guard
21
21
  end
22
22
 
23
+ it 'fork の前後で poller を張り直すフックを登録する' do
24
+ allow(CopyTunerClient::ForkHook).to receive(:install)
25
+
26
+ build_process_guard.start
27
+
28
+ expect(CopyTunerClient::ForkHook).to have_received(:install)
29
+ end
30
+
23
31
  it 'starts polling from a worker process' do
24
32
  process_guard = build_process_guard
25
33
  process_guard.start
@@ -0,0 +1,27 @@
1
+ require 'spec_helper'
2
+ require 'timeout'
3
+
4
+ describe CopyTunerClient::QueueWithTimeout do
5
+ subject(:queue) { described_class.new }
6
+
7
+ describe '#pop_with_timeout' do
8
+ it 'キューに要素があれば取り出す' do
9
+ queue << :sync
10
+
11
+ expect(queue.pop_with_timeout(0)).to eq(:sync)
12
+ end
13
+
14
+ it '要素が無いままタイムアウトしたら ThreadError を投げる' do
15
+ expect { queue.pop_with_timeout(0.1) }.to raise_error(ThreadError)
16
+ end
17
+
18
+ it 'ウォールクロックが巻き戻ってもタイムアウトが伸びない' do
19
+ # 待っている最中に NTP の step 補正や手動の時刻変更で時計が後ろへ飛ぶ状況を模す。
20
+ # 経過時間をウォールクロックで測っていると、飛んだ幅がそのまま待ち時間に乗る
21
+ base = Time.now
22
+ allow(Time).to receive(:now).and_return(base, base - 3600)
23
+
24
+ expect { Timeout.timeout(2) { queue.pop_with_timeout(0.1) } }.to raise_error(ThreadError)
25
+ end
26
+ end
27
+ end
data/spec/spec_helper.rb CHANGED
@@ -26,4 +26,10 @@ RSpec.configure do |config|
26
26
  FakeCopyTunerApp.reset
27
27
  reset_config
28
28
  end
29
+
30
+ # apply を通す spec は本物の poller スレッドを起動するので、example ごとに止める。
31
+ # 放置するとスレッドがスイート終了まで積み上がり、実際に HTTP を叩き続ける
32
+ config.after do
33
+ CopyTunerClient.configuration&.poller&.stop
34
+ end
29
35
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: copy_tuner_client
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.2.1
4
+ version: 2.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - SonicGarden
@@ -200,6 +200,7 @@ files:
200
200
  - app/assets/javascripts/copytuner.js
201
201
  - biome.json
202
202
  - copy_tuner_client.gemspec
203
+ - docs/poller-startup.md
203
204
  - gemfiles/8.0.gemfile
204
205
  - gemfiles/8.1.gemfile
205
206
  - gemfiles/main.gemfile
@@ -215,6 +216,7 @@ files:
215
216
  - lib/copy_tuner_client/dotted_hash.rb
216
217
  - lib/copy_tuner_client/engine.rb
217
218
  - lib/copy_tuner_client/errors.rb
219
+ - lib/copy_tuner_client/fork_hook.rb
218
220
  - lib/copy_tuner_client/helper_extension.rb
219
221
  - lib/copy_tuner_client/i18n_backend.rb
220
222
  - lib/copy_tuner_client/i18n_compat.rb
@@ -251,11 +253,13 @@ files:
251
253
  - spec/copy_tuner_client/copyray_middleware_spec.rb
252
254
  - spec/copy_tuner_client/copyray_spec.rb
253
255
  - spec/copy_tuner_client/dotted_hash_spec.rb
256
+ - spec/copy_tuner_client/fork_hook_spec.rb
254
257
  - spec/copy_tuner_client/helper_extension_spec.rb
255
258
  - spec/copy_tuner_client/i18n_backend_spec.rb
256
259
  - spec/copy_tuner_client/poller_spec.rb
257
260
  - spec/copy_tuner_client/prefixed_logger_spec.rb
258
261
  - spec/copy_tuner_client/process_guard_spec.rb
262
+ - spec/copy_tuner_client/queue_with_timeout_spec.rb
259
263
  - spec/copy_tuner_client/request_sync_spec.rb
260
264
  - spec/copy_tuner_client/translation_log_spec.rb
261
265
  - spec/copy_tuner_client/warden_integration_spec.rb