copy_tuner_client 2.2.0 → 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 +4 -4
- data/CHANGELOG.md +2 -235
- data/CLAUDE.md +10 -0
- data/README.md +42 -5
- data/docs/poller-startup.md +108 -0
- data/lib/copy_tuner_client/configuration.rb +37 -12
- data/lib/copy_tuner_client/fork_hook.rb +53 -0
- data/lib/copy_tuner_client/poller.rb +87 -23
- data/lib/copy_tuner_client/process_guard.rb +13 -4
- data/lib/copy_tuner_client/queue_with_timeout.rb +8 -2
- data/lib/copy_tuner_client/version.rb +1 -1
- data/skills/copy-tuner-to-locales-cleanup/SKILL.md +5 -5
- data/skills/copy-tuner-to-locales-migrate-prefix/SKILL.md +69 -37
- data/skills/copy-tuner-to-locales-migrate-prefix/references/example-touchpoints.md +31 -10
- data/skills/copy-tuner-to-locales-migrate-prefix/scripts/migrate_prefix.rb +144 -6
- data/spec/copy_tuner_client/configuration_spec.rb +173 -0
- data/spec/copy_tuner_client/fork_hook_spec.rb +171 -0
- data/spec/copy_tuner_client/poller_spec.rb +148 -1
- data/spec/copy_tuner_client/process_guard_spec.rb +8 -0
- data/spec/copy_tuner_client/queue_with_timeout_spec.rb +27 -0
- data/spec/copy_tuner_client/warden_integration_spec.rb +95 -0
- data/spec/spec_helper.rb +6 -0
- data/spec/support/middleware_stack.rb +39 -5
- metadata +6 -1
|
@@ -8,6 +8,10 @@
|
|
|
8
8
|
# bin/rails runner .claude/skills/copy-tuner-to-locales-migrate-prefix/scripts/migrate_prefix.rb \
|
|
9
9
|
# -- --prefix date --export tmp/copy_tuner_all.yml --out config/locales/0010_date.yml
|
|
10
10
|
#
|
|
11
|
+
# このスクリプト自身の自己検証(gem メンテナ向け。Rails も bundler も不要):
|
|
12
|
+
#
|
|
13
|
+
# ruby skills/copy-tuner-to-locales-migrate-prefix/scripts/migrate_prefix.rb --self-test
|
|
14
|
+
#
|
|
11
15
|
# What it does, in one pass:
|
|
12
16
|
# (1) Place : extract the target prefix subtree from the 0000_original_*.yml
|
|
13
17
|
# originals (load-order deep_merge), deep_merge the export subtree on top
|
|
@@ -29,6 +33,127 @@
|
|
|
29
33
|
require 'optparse'
|
|
30
34
|
require 'yaml'
|
|
31
35
|
|
|
36
|
+
# ---- self-test ----
|
|
37
|
+
# 引数パースより前に分岐する。このスクリプトはトップレベルで即実行する造りなので、`--self-test` の
|
|
38
|
+
# ときは本体へ進ませずテストだけ走らせてトップレベル return で抜ける。
|
|
39
|
+
if ARGV.delete('--self-test')
|
|
40
|
+
require 'minitest/autorun'
|
|
41
|
+
require 'tmpdir'
|
|
42
|
+
require 'fileutils'
|
|
43
|
+
require 'rbconfig'
|
|
44
|
+
|
|
45
|
+
# 本体はトップレベルで exit するため load では検証できない。fixture を tmpdir に作り、i18n だけを
|
|
46
|
+
# 直接設定する薄いランナー経由でサブプロセス実行する(スクリプトが触るのは I18n.load_path と
|
|
47
|
+
# Backend::Simple だけなので Rails は不要)。
|
|
48
|
+
class MigratePrefixSelfTest < Minitest::Test
|
|
49
|
+
SCRIPT = File.expand_path(__FILE__)
|
|
50
|
+
# 対象 prefix(date)を持つファイル。これだけが書き戻し対象になるのが期待挙動。
|
|
51
|
+
WITH_PREFIX = "ja:\n date:\n formats:\n default: \"%Y\"\n"
|
|
52
|
+
WITHOUT_PREFIX = "ja:\n greeting: \"hello\"\n"
|
|
53
|
+
# 片方だけが対象 prefix を持つ 2 ファイル構成。「1 ファイルだけ書き戻る」ことの検証に使う。
|
|
54
|
+
MIXED_FILES = { '0000_original_a.yml' => WITH_PREFIX, '0000_original_b.yml' => WITHOUT_PREFIX }.freeze
|
|
55
|
+
EXPORT_JA = "ja:\n date:\n formats:\n default: \"%Y/%m/%d\"\n"
|
|
56
|
+
EXPORT_JA_EN = "#{EXPORT_JA}en:\n date:\n formats:\n default: \"%m/%d/%Y\"\n".freeze
|
|
57
|
+
# to_yaml の既定 line_width(80)を確実に超える長さにして折り返しを誘発する。
|
|
58
|
+
LONG_VALUE = (['word'] * 60).join(' ').freeze
|
|
59
|
+
|
|
60
|
+
def test_untouched_file_keeps_bytes
|
|
61
|
+
# コメント・空行・ダブルクォートが 1 バイトも変わらないことを見る。
|
|
62
|
+
untouched = <<~YAML
|
|
63
|
+
ja:
|
|
64
|
+
# これは greeting のコメント
|
|
65
|
+
greeting: "hello"
|
|
66
|
+
|
|
67
|
+
farewell: "bye"
|
|
68
|
+
YAML
|
|
69
|
+
migrate({ '0000_original_a.yml' => WITH_PREFIX, '0000_original_b.yml' => untouched }) do |out, dir|
|
|
70
|
+
# binread は ASCII-8BIT を返すため、UTF-8 の期待値を b で揃えてバイト列として比較する。
|
|
71
|
+
assert_equal(untouched.b, read(dir, '0000_original_b.yml'), "無関係なファイルが書き換わった:\n#{out}")
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def test_missing_locale_root_is_not_added
|
|
76
|
+
migrate(MIXED_FILES, locales: %w[ja en], export: EXPORT_JA_EN) do |_out, dir|
|
|
77
|
+
MIXED_FILES.each_key do |name|
|
|
78
|
+
refute_includes(read(dir, name), 'en:', "#{name} に元ファイルに無い en ルートが新設された")
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def test_prefix_is_pruned_from_matching_file
|
|
84
|
+
migrate({ '0000_original_a.yml' => "#{WITH_PREFIX} greeting: \"hello\"\n" }) do |_out, dir|
|
|
85
|
+
assert_equal({ 'ja' => { 'greeting' => 'hello' } }, load_yaml(dir, '0000_original_a.yml'))
|
|
86
|
+
assert_equal(
|
|
87
|
+
{ 'ja' => { 'date' => { 'formats' => { 'default' => '%Y/%m/%d' } } } },
|
|
88
|
+
load_yaml(dir, '0010_migrated.yml')
|
|
89
|
+
)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def test_long_value_is_not_wrapped
|
|
94
|
+
migrate({ '0000_original_a.yml' => "#{WITH_PREFIX} greeting: \"#{LONG_VALUE}\"\n" }) do |_out, dir|
|
|
95
|
+
body = read(dir, '0000_original_a.yml')
|
|
96
|
+
assert_includes(body, "greeting: #{LONG_VALUE}\n", "長い値が折り返された:\n#{body}")
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def test_summary_counts_changed_files
|
|
101
|
+
migrate(MIXED_FILES) do |out, _dir|
|
|
102
|
+
assert_includes(out, 'オリジナル 2 ファイル中 1 ファイル')
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
private
|
|
107
|
+
|
|
108
|
+
# fixture を tmpdir に配置してサブプロセス実行し、標準出力と locales ディレクトリを yield する。
|
|
109
|
+
def migrate(files, locales: %w[ja], export: EXPORT_JA)
|
|
110
|
+
Dir.mktmpdir do |dir|
|
|
111
|
+
locales_dir = File.join(dir, 'config/locales')
|
|
112
|
+
FileUtils.mkdir_p(locales_dir)
|
|
113
|
+
FileUtils.mkdir_p(File.join(dir, 'tmp'))
|
|
114
|
+
files.each { |name, body| File.write(File.join(locales_dir, name), body) }
|
|
115
|
+
File.write(File.join(dir, 'tmp/export.yml'), export)
|
|
116
|
+
write_runner(dir, locales)
|
|
117
|
+
|
|
118
|
+
ok, out = spawn_script(dir, locales)
|
|
119
|
+
assert(ok, "スクリプトが異常終了した:\n#{out}")
|
|
120
|
+
yield(out, locales_dir)
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def write_runner(dir, locales)
|
|
125
|
+
File.write(File.join(dir, 'runner.rb'), <<~RUBY)
|
|
126
|
+
require 'i18n'
|
|
127
|
+
I18n.load_path = Dir['config/locales/*.yml']
|
|
128
|
+
I18n.available_locales = #{locales.map(&:to_sym).inspect}
|
|
129
|
+
I18n.default_locale = #{locales.first.to_sym.inspect}
|
|
130
|
+
load #{SCRIPT.inspect}
|
|
131
|
+
RUBY
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def spawn_script(dir, locales)
|
|
135
|
+
args = [
|
|
136
|
+
'--prefix', 'date', '--locales', locales.join(','),
|
|
137
|
+
'--export', 'tmp/export.yml', '--out', 'config/locales/0010_migrated.yml'
|
|
138
|
+
]
|
|
139
|
+
Dir.chdir(dir) do
|
|
140
|
+
read_io, write_io = IO.pipe
|
|
141
|
+
pid = spawn(RbConfig.ruby, File.join(dir, 'runner.rb'), '--', *args, out: write_io, err: write_io)
|
|
142
|
+
write_io.close
|
|
143
|
+
out = read_io.read
|
|
144
|
+
_, status = Process.waitpid2(pid)
|
|
145
|
+
[status.success?, out]
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def read(dir, name) = File.binread(File.join(dir, name))
|
|
150
|
+
|
|
151
|
+
def load_yaml(dir, name) = YAML.safe_load_file(File.join(dir, name))
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
return
|
|
155
|
+
end
|
|
156
|
+
|
|
32
157
|
# NOTE: `bin/rails runner` は Kernel#abort が投げる SystemExit を握りつぶし終了コードが 0 になる
|
|
33
158
|
# (実機確認済み)。中断を呼び出し側へ確実に伝えるため、abort ではなく warn + exit(1) を使う。
|
|
34
159
|
def die(message)
|
|
@@ -160,7 +285,12 @@ end
|
|
|
160
285
|
# locale ルートを持つ raw Hash(`{ "ja" => {...}, "en" => {...} }`)から、対象 prefix を全 locale で刈った
|
|
161
286
|
# 新しい Hash を返す(非破壊)。移行漏れ検証のシミュレーションと実削除の両方で使う。
|
|
162
287
|
def prune_prefix_all_locales(raw, locales, keys)
|
|
163
|
-
locales.reduce(raw)
|
|
288
|
+
locales.reduce(raw) do |acc, locale|
|
|
289
|
+
# 元ファイルに無い locale ルートを新設すると、ja だけのファイルに `en: {}` が追記されてしまう。
|
|
290
|
+
next acc unless acc.key?(locale)
|
|
291
|
+
|
|
292
|
+
acc.merge(locale => prune_prefix(acc[locale], keys))
|
|
293
|
+
end
|
|
164
294
|
end
|
|
165
295
|
|
|
166
296
|
# ---- (1) 配置 ----
|
|
@@ -277,10 +407,18 @@ unless leaks.empty?
|
|
|
277
407
|
die('中断。--out の内容・採番・regexp を確認すること。')
|
|
278
408
|
end
|
|
279
409
|
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
410
|
+
changed =
|
|
411
|
+
original_files.count do |f|
|
|
412
|
+
raw = YAML.safe_load_file(f, permitted_classes: [Symbol], aliases: true) || {}
|
|
413
|
+
pruned = prune_prefix_all_locales(raw, LOCALES, prefix_keys)
|
|
414
|
+
# 対象 prefix を含まないファイルを to_yaml で書き戻すと、クォート・アンカー名・折り返し・コメントの
|
|
415
|
+
# 無関係な整形差分が出る。実質的な変更があるファイルだけ書き戻す。
|
|
416
|
+
next false if pruned == raw
|
|
417
|
+
|
|
418
|
+
# 折り返しだけは line_width で無効化できる(アンカー名・クォートは Psych の仕様で制御できない)。
|
|
419
|
+
File.write(f, pruned.to_yaml(line_width: -1))
|
|
420
|
+
true
|
|
421
|
+
end
|
|
284
422
|
|
|
285
|
-
puts "削除: prefix '#{PREFIX}' をオリジナル #{original_files.size} ファイルから刈り取った。"
|
|
423
|
+
puts "削除: prefix '#{PREFIX}' をオリジナル #{original_files.size} ファイル中 #{changed} ファイルから刈り取った。"
|
|
286
424
|
puts '完了。手順7(local_first_key_regexp 追加)が未済なら次に実施すること。'
|
|
@@ -398,6 +398,179 @@ describe CopyTunerClient::Configuration do
|
|
|
398
398
|
end
|
|
399
399
|
end
|
|
400
400
|
|
|
401
|
+
describe 'middleware_position の初期値' do
|
|
402
|
+
let(:config) { described_class.new }
|
|
403
|
+
|
|
404
|
+
context 'Warden::Manager と Devise がどちらも定義済みのとき' do
|
|
405
|
+
before do
|
|
406
|
+
stub_const('Warden::Manager', Class.new)
|
|
407
|
+
stub_const('Devise', Module.new)
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
it '{ before: Warden::Manager } になる' do
|
|
411
|
+
expect(config.middleware_position).to eq({ before: Warden::Manager })
|
|
412
|
+
end
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
context 'Warden::Manager が未定義のとき' do
|
|
416
|
+
before { hide_const('Warden::Manager') }
|
|
417
|
+
|
|
418
|
+
it 'nil のままになる' do
|
|
419
|
+
expect(config.middleware_position).to be_nil
|
|
420
|
+
end
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
# NOTE: authtrail のように warden を require するだけで Warden::Manager をスタックへ積まない
|
|
424
|
+
# gem があるため、Warden 単独では既定位置にしない(insert_before が起動時例外になる)。
|
|
425
|
+
context 'Warden::Manager は定義済みだが Devise が未定義のとき' do
|
|
426
|
+
before do
|
|
427
|
+
stub_const('Warden::Manager', Class.new)
|
|
428
|
+
hide_const('Devise')
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
it 'nil のままになる' do
|
|
432
|
+
expect(config.middleware_position).to be_nil
|
|
433
|
+
end
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
context 'configure ブロック内で明示指定したとき' do
|
|
437
|
+
before do
|
|
438
|
+
stub_const('Warden::Manager', Class.new)
|
|
439
|
+
stub_const('Devise', Module.new)
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
it '明示指定した値が優先される' do
|
|
443
|
+
other = Class.new
|
|
444
|
+
CopyTunerClient.configure(apply: false) do |c|
|
|
445
|
+
c.middleware_position = { before: other }
|
|
446
|
+
end
|
|
447
|
+
expect(CopyTunerClient.configuration.middleware_position).to eq({ before: other })
|
|
448
|
+
end
|
|
449
|
+
end
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
context 'Warden::Manager がスタックにある状態で apply したとき' do
|
|
453
|
+
it_behaves_like 'applied configuration' do
|
|
454
|
+
it 'デフォルト値経由で Warden::Manager の直前に RequestSync → CopyrayMiddleware の順で入る' do
|
|
455
|
+
expect(middleware.classes).to eq(
|
|
456
|
+
[
|
|
457
|
+
Rack::ETag, Rack::TempfileReaper, CopyTunerClient::RequestSync, CopyTunerClient::CopyrayMiddleware,
|
|
458
|
+
Warden::Manager, Rack::Static
|
|
459
|
+
]
|
|
460
|
+
)
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
it 'Warden::Manager より外側に配置される' do
|
|
464
|
+
expect(middleware.index(CopyTunerClient::RequestSync)).to be < middleware.index(Warden::Manager)
|
|
465
|
+
expect(middleware.index(CopyTunerClient::CopyrayMiddleware)).to be < middleware.index(Warden::Manager)
|
|
466
|
+
end
|
|
467
|
+
|
|
468
|
+
it 'RequestSync に poller / cache / interval / ignore_regex が渡る' do
|
|
469
|
+
args = middleware.args_for(CopyTunerClient::RequestSync)
|
|
470
|
+
expect(args.first).to include(
|
|
471
|
+
poller:, # rubocop:disable Sgcop/Rspec/NoMethodCallInExpectation
|
|
472
|
+
cache:,
|
|
473
|
+
interval: configuration.sync_interval,
|
|
474
|
+
ignore_regex: configuration.sync_ignore_path_regex
|
|
475
|
+
)
|
|
476
|
+
end
|
|
477
|
+
end
|
|
478
|
+
|
|
479
|
+
# NOTE: 実アプリの bin/rails middleware 出力を模した標準スタック。Warden::Manager / Devise は
|
|
480
|
+
# gem の依存にないため stub_const で fake の定数として定義する。
|
|
481
|
+
before do
|
|
482
|
+
stub_const('Warden::Manager', Class.new)
|
|
483
|
+
stub_const('Devise', Module.new)
|
|
484
|
+
end
|
|
485
|
+
|
|
486
|
+
let(:middleware) do
|
|
487
|
+
MiddlewareStack.new([Rack::ETag, Rack::TempfileReaper, Warden::Manager, Rack::Static])
|
|
488
|
+
end
|
|
489
|
+
|
|
490
|
+
def apply
|
|
491
|
+
configuration.middleware = middleware
|
|
492
|
+
configuration.environment_name = 'development'
|
|
493
|
+
configuration.apply
|
|
494
|
+
end
|
|
495
|
+
end
|
|
496
|
+
|
|
497
|
+
context 'Warden::Manager が未定義の状態で apply したとき' do
|
|
498
|
+
it_behaves_like 'applied configuration' do
|
|
499
|
+
it 'Warden::Manager 未定義時は従来どおりスタック末尾へ use される' do
|
|
500
|
+
expect(middleware.classes).to eq([CopyTunerClient::RequestSync, CopyTunerClient::CopyrayMiddleware])
|
|
501
|
+
end
|
|
502
|
+
end
|
|
503
|
+
|
|
504
|
+
before { hide_const('Warden::Manager') }
|
|
505
|
+
|
|
506
|
+
let(:middleware) { MiddlewareStack.new }
|
|
507
|
+
|
|
508
|
+
def apply
|
|
509
|
+
configuration.middleware = middleware
|
|
510
|
+
configuration.environment_name = 'development'
|
|
511
|
+
configuration.apply
|
|
512
|
+
end
|
|
513
|
+
end
|
|
514
|
+
|
|
515
|
+
context 'middleware_position に after を明示指定して apply したとき' do
|
|
516
|
+
it_behaves_like 'applied configuration' do
|
|
517
|
+
it '{after: X} 指定時は X の直後に RequestSync → CopyrayMiddleware の順で入る' do
|
|
518
|
+
expect(middleware.classes).to eq(
|
|
519
|
+
[:a, :x, CopyTunerClient::RequestSync, CopyTunerClient::CopyrayMiddleware, :b]
|
|
520
|
+
)
|
|
521
|
+
end
|
|
522
|
+
end
|
|
523
|
+
|
|
524
|
+
let(:middleware) { MiddlewareStack.new(%i[a x b]) }
|
|
525
|
+
|
|
526
|
+
def apply
|
|
527
|
+
configuration.middleware = middleware
|
|
528
|
+
configuration.middleware_position = { after: :x }
|
|
529
|
+
configuration.environment_name = 'development'
|
|
530
|
+
configuration.apply
|
|
531
|
+
end
|
|
532
|
+
end
|
|
533
|
+
|
|
534
|
+
context 'middleware_position に before を明示指定して apply したとき' do
|
|
535
|
+
it_behaves_like 'applied configuration' do
|
|
536
|
+
it '{before: X} 指定時は X の直前に RequestSync → CopyrayMiddleware の順で入る' do
|
|
537
|
+
expect(middleware.classes).to eq(
|
|
538
|
+
[:a, CopyTunerClient::RequestSync, CopyTunerClient::CopyrayMiddleware, :x, :b]
|
|
539
|
+
)
|
|
540
|
+
end
|
|
541
|
+
end
|
|
542
|
+
|
|
543
|
+
let(:middleware) { MiddlewareStack.new(%i[a x b]) }
|
|
544
|
+
|
|
545
|
+
def apply
|
|
546
|
+
configuration.middleware = middleware
|
|
547
|
+
configuration.middleware_position = { before: :x }
|
|
548
|
+
configuration.environment_name = 'development'
|
|
549
|
+
configuration.apply
|
|
550
|
+
end
|
|
551
|
+
end
|
|
552
|
+
|
|
553
|
+
# NOTE: { before: SomeClass if cond } のように条件次第で値が nil になる書き方を想定する。
|
|
554
|
+
# キーの有無だけで分岐すると insert_before(nil) が対象を見つけられず例外になる。
|
|
555
|
+
context 'middleware_position の値が nil のとき' do
|
|
556
|
+
it_behaves_like 'applied configuration' do
|
|
557
|
+
it '例外を投げずスタック末尾へ use する' do
|
|
558
|
+
expect(middleware.classes).to eq(
|
|
559
|
+
[:a, :x, :b, CopyTunerClient::RequestSync, CopyTunerClient::CopyrayMiddleware]
|
|
560
|
+
)
|
|
561
|
+
end
|
|
562
|
+
end
|
|
563
|
+
|
|
564
|
+
let(:middleware) { MiddlewareStack.new(%i[a x b]) }
|
|
565
|
+
|
|
566
|
+
def apply
|
|
567
|
+
configuration.middleware = middleware
|
|
568
|
+
configuration.middleware_position = { before: nil }
|
|
569
|
+
configuration.environment_name = 'development'
|
|
570
|
+
configuration.apply
|
|
571
|
+
end
|
|
572
|
+
end
|
|
573
|
+
|
|
401
574
|
context 'applied without locale filter' do
|
|
402
575
|
include_context 'stubbed configuration'
|
|
403
576
|
|
|
@@ -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]
|
|
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
|