2022/06/04
Unix Processes
Descriptors Represent Resources
irb(main):002:0> passwd = File.open('/etc/passwd') => #<File:/etc/passwd> irb(main):003:0> puts passwd.fileno 9 => nil irb(main):004:0> hosts = File.open('/etc/hosts') => #<File:/etc/hosts> irb(main):005:0> puts hosts.fileno 10 => nil irb(main):006:0> passwd.close => nil irb(main):009:0> null = File.open('/dev/null') => #<File:/dev/null> irb(main):010:0> puts null.fileno 9 nil
ファイルディスクリプタは,アクセスしているファイルに番号がつけ,プロセスがアクセスしているリソースを特定する。ファイルが閉じられれば,そのリソースにつけられていた番号は再び使えるようになる。
Standard Streams
STDIN(標準入力),STDOUT(標準出力),STDERR(標準エラー出力)はそれぞれ,1,2,3のファイルディスクリプタの番号がつけられている。
irb(main):006:0> puts STDIN.fileno 0 => nil irb(main):007:0> puts STDOUT.fileno 1 => nil irb(main):008:0> puts STDERR.fileno 2 => nil
Processes Have an Environment
親のプロセスの環境変数は子プロセスにも受け継がれる。Railsだと,RAILS_ENVなど。
Processes Have Exit Code
- exit code 0は成功
- その他のexit codeはエラー
How to Exit a Process
exit
- exit codeを渡せる
exit 22
- at_exitでexit時の処理を渡せる
at_exit { puts 'Last!' } exit
exit!
- status codeは1
- at_exitの処理が走らない
abort
- status codeは1
- 引数で文字列を渡せる
- at_exitの処理が走る
Processes can fork
親プロセスがメインメモリに持つコピー全てが子プロセスに受け継がれる。一つのプロセスを親プロセスとして,二つの子プロセスがforkされる時,3つのプロセスがアプリケーションをロードするよりも高速になる。
forkメソッド
irb(main):001:0> puts "parent process pid is #{Process.pid}" parent process pid is 21210 => nil irb(main):002:1* if fork irb(main):003:1* puts "entered the if block from #{Process.pid}" irb(main):004:1* else irb(main):005:1* puts "entered the else block from #{Process.pid}" irb(main):006:0> end entered the if block from 21210 => nil entered the else block from 21223 => nil
ifにおけるfork
は親プロセスからforkして作られた子プロセスのpidが返されるため,if内の処理が走り,その次のfork
は子プロセスのfork
となり,nilが返るためelse内の処理が走る。
irb(main):001:0> puts Process.pid 21257 irb(main):002:1* fork do irb(main):003:1* puts Process.pid irb(main):004:1* puts Process.ppid irb(main):005:0> end => 21291 21291 21257
fork
メソッドのブロック内は,子プロセス内の処理が行われる。
Orphaned Processes
fork do 5.times do sleep 1 puts "I'm an orphan!" end end abort "Parent process died..."
上記のコードの出力結果は以下となる。
irb(main):001:1* fork do irb(main):002:2* 5.times do irb(main):003:2* sleep 1 irb(main):004:2* puts "I'm an orphan!" irb(main):005:1* end irb(main):006:0> end => 21575 irb(main):007:0> abort "a" a ~ % I'm an orphan! # 親プロセスはexitし,ターミナル上の出力 I'm an orphan! I'm an orphan! I'm an orphan! I'm an orphan!
abort "a"
で,fork
内の子プロセスの親プロセスは終了するが,子プロセスの処理は中止されず,irb
の親プロセスのターミナル上でfork
内の子プロセスの処理が継続する。
Being CoW Friendly
copy-on-write
親プロセスをforkして子プロセスを作成した瞬間に親プロセスのデータが全てコピーされるわけではなく,子プロセスのデータに変更を加えてようとしたタイミングで始めて親プロセスのデータのコピーが行われる。
irb(main):015:0> arr = [1,2,3] => [1, 2, 3] irb(main):016:1* fork do irb(main):017:1* p arr # 親プロセスのコピーは起こっていない irb(main):018:1* arr << 4 # 子プロセスのデータに変更を加えようとして始めて親プロセスのデータがコピーされる irb(main):019:1* p arr irb(main):020:0> end => 21690 [1, 2, 3] [1, 2, 3, 4] irb(main):021:0> p arr [1, 2, 3] => [1, 2, 3]
上記のアルゴリズムは,mark-and-sweepアルゴリズムを使用している。
Mark and Sweep Algorithm
Mark and Sweep Algorithmとは
Garbage Collectionのアルゴリズム。Garbage Collectionは,参照されていないメモリを見つけ,解放することを行う。
手順
- メモリに,デフォルトで0(false)のマークをつける。そして,参照できるメモリには深さ優先探索により1(true)のマークを付ける。
- 参照されていないメモリ領域(falseマークのついている領域)を解放し,参照されているメモリ領域のマークをtrueからfalseに変更する。
メリット
- 循環参照の場合でも無限ループにならない
- アルゴリズムの処理中に余分なオーバヘッドが発生しない
デメリット
- Garbage Collectionの処理が実行中の時,メインプログラムが中断される
- Mark and Sweepアルゴリズムが複数回実行された後,使用されているメモリ空間が多くの小さな領域に分割されてしまう(フラグメンテーション)
参考
参考
2022/06/02
プロセスとスレッド
MMU
- Memory Management Unit
- プロセスごとに専用の物理メモリ領域を確保し,その物理領域にアクセスするための仮想アドレスを用意し,両者のアドレスのマッピングを行う
- マッピング情報はページテーブルという名前でメモリ上に保持される
プロセスがメモリに保持している情報
- テキストセグメント
- プログラムの命令列
- データセグメント
プロセスとスレッドの違い
1つのプログラムをプロセスとスレッドで並列に処理を行う時を考える。
プロセス
テキストセグメントは親と子プロセスで共有し,データセグメントは親と子プロセスで違う領域を持つ。
スレッド
ほぼ全てのデータをスレッドと共有する。
パフォーマンスの違い
プロセスの場合
プロセスごとに固有のメモリ領域を持つため,プロセスを切り替える場合は,仮想アドレスと物理アドレスのマッピングをそのプロセス用のものへと切り替える必要がある。
1度アドレスを解決した結果はMMUがキャッシュしているため,プロセスを切り替えるごとにMMUが持っているキャッシュをクリアする必要がある(TLBフラッシュ)。TLBフラッシュ直後は全てのキャッシュが消えるので,コストが大きい。
スレッドの場合
スレッドごとに共通のメモリ領域を持つため,利用する物理アドレスと仮想アドレスのマッピング情報を変える必要はなく,TLBフラッシュも起こらない。
参考
Unix Processes
System Calls
Kernelはハードウェアでの処理を仲介する。直接Kernelにアクセスすることはなく,system callを通してアクセスできる。
Processes Have Parents
システム上のプロセスにはその親プロセスが存在する。
参考
2022/06/01
2022/05/31
NginxとApache
参考
C10K問題とは
ハードウェアの性能上は問題がなくても,あまりにもクライアントの数が多くなるとサーバーがパンクする問題のこと。プロセスやスレッドが多くなることが原因となる。
プロセスやスレッドが多くなると
- プロセスの上限に達する
- ファイルディスクリプタ(ファイルを識別するための目印)数の上限に達する
- コンテキストスイッチにオーバーヘッドが発生し,負荷が大きくなる
- コンテキストスイッチ
- CPUのコンテキストがプロセスに紐づいており,このコンテキストにそのプロセスで最後に実行した状態が記憶されており,プロセスの切り替えごとにそのプロセスに紐づくコンテキストが読み込まれる
- https://milestone-of-se.nesuke.com/sv-basic/architecture/cpu/
- コンテキストスイッチ
Apache
preforkモデル
- 1プロセスあたり1スレッド
- プロセス数の上限に達しやすくなる
workerモデル
- 1プロセスあたり複数スレッド
- ファイルディスクリプタの上限(1プロセスあたり1024)に達しやすくなる
Nginx
概要
1プロセス1スレッドで複数のアクセスを捌ける。
特徴
- ノンブロッキングI/O
- イベント駆動
- クライアントのアクセスをイベントとして扱い,それをきっかけにプロセス内で処理を開始する
ノンブロッキングI/Oとは
参考
ブロッキングI/O・同期I/O
KernelのI/O処理中,プロセスは待ち状態となり,プログラムの処理が止まる。
ノンブロッキングI/O
KernelのI/O処理中,プロセスは待ち状態とはならず,他の処理を行うことができ,Kernelの処理中はエラーが返される。
非同期I/O
KernelのI/O処理中,プロセスは待ち状態とはならず,他の処理を行うことができ,I/O処理が終了すれば,完了通知が来る。
ApacheとNginxの比較
Apache
- configの文法的に静的配信に向いている
Nginx
- リバースプロキシとして動的配信を行えるように設定されている
- ただのPort転送ならApacheに比べて簡素な設定で終わる
2022/05/30
書籍
Real World HTTP ミニ版
メモ
チャンク
概要
全体を一括で送信するのではなく,小分けにして送信する。時間のかかるデータ転送を少しずつ前倒しで行うことができる。
メリット1
サーバーの負荷軽減
具体例
ライブ動画を配信するときに,動画の先頭から順番に返すことができる。
サーバー側のメリット
転送に必要なブロックだけをメモリにロードしてTCPソケットにデータを流し込むことができ,1GBの動画ファイルを送信する場合でも,メモリを1GB消費することはない。
クライアント側のメリット
サーバー側が最後のデータの準備ができた頃には,それまでのデータが既に転送済みなので,リードタイムを短くできる。
メリット2
クライアント側の通信の最適化に繋がる
具体例
本文を動的生成するページで,表示に時間がかかる場合は,ヘッダー部分だけをチャンクに分けて先に返すことができる。HTMLファイルよりも先にCSSなどの表示に必要なファイルのダウンロードを行える。
Rubyでの並行処理
並列処理について学習する機会があったため,Rubyで知識のブラッシュアップを行う。
プロセスとスレッド
両者の名前を頻繁に聞くが,全く意味を理解していなかった。プロセスは実行されるプログラムの単位で,その中にスレッドが1つ以上存在する。
そして,1つのスレッドは1つのコアによって動かされる。
また,プロセスごとでメモリ空間は独立しているが,マルチスレッドの場合,同じプロセス内に存在するので共有のメモリ空間を使用する。
並行処理と並列処理
両者の区別が曖昧に使われていることが多いと思うが,以下の説明が理解しやすかった。
並行処理: ある時間の範囲において、複数のタスクを扱うこと 並列処理: ある時間の点において、複数のタスクを扱うこと
並行処理実践
以下では,実際にRubyを使用して,並行処理を試してみる。
https://tech.unifa-e.com/entry/2021/01/29/175021 を参考にした。
通常の処理
実装は以下となる。
def sleepy puts Process.pid sleep 3 end def test start_time = Time.now 4.times { sleepy } end_time = Time.now puts end_time - start_time end
sleepy
では,プロセスのIDを出力し,3秒間待つ。test
では,sleepy
を4回普通に実行し,その経過時間を計測した。
test
メソッドの実行結果は以下となる。
89867 89867 89867 89867 12.00396
全てのプロセスIDは等しく,経過時間も3*4=12秒で,逐次的に処理が行われることがわかる。
スレッドでの並行処理
実装は以下となる。
def test1 start_time = Time.now Parallel.each(Array.new(4), in_threads: 4) { sleepy } end_time = Time.now puts end_time - start_time end
実行結果は以下となる。
89867 89867 89867 89867 3.005605
全て同じプロセス内のスレッドで実行されているため,プロセスIDは等しく,しかし並行で処理が行われているため,処理時間は3秒となる。
プロセスでの並行処理
実装は以下となる。
def test2 start_time = Time.now Parallel.each(Array.new(4), in_processes: 4) { sleepy } end_time = Time.now puts end_time - start_time end
実行結果は以下となる。
90074 90075 90076 90077 3.046518
4つのプロセス内の各スレッドで処理が実行されているため,全てのプロセスIDは異なり,並行で処理が行われているため,実行時間は3秒となる。
補足
参考リンク内では,並行処理と記載されていることが最初は理解できなかった。並列処理が実行されているのではないかと思っていた。そこで,まずは自分のPCのコア数を調べてみた。
sysctl -n hw.physicalcpu_max => 4 # 物理コア数
よって,1コアあたり2スレッドの実行を行うことができるため,8個のスレッドを並列で処理を行うことができる。そこで,100個のプロセスを立てて処理を実行してみる。
実装は以下となる。
def test3 start_time = Time.now Parallel.each(Array.new(20), in_processes: 20) { sleepy } end_time = Time.now puts end_time - start_time end
処理結果は以下となる。
91009 91010 91011 91012 91013 91015 91014 91017 91016 91019 91018 91021 91022 91020 91023 91024 91028 91025 91027 91026 3.08953
プロセスが20個立ち,処理時間は3秒となっている。CPUのコア数を考えれば最大で8個までしかプロセスが立たないのに,それを超える20個のプロセスが立っている。というのも,今回のケースでは,20個のプロセスが並列で処理されているわけではなく,並行で処理されているためこのようにCPUのコア数を超えるプロセスを立てることができる。よってこれが理由でブログでは並行処理と正しく記載されていた。
Fiberを使った処理
参考ブログでも載せられてるように,Fiberで並行処理を行う実装が複雑だったため,asyncというgemを使用して並行処理を実装する。実装は以下。
require 'async' def sleepy Async do puts Process.pid sleep 3 end end def test5 start_time = Time.now task = Async do 4.times { sleepy } end task.wait end_time = Time.now puts end_time - start_time end
実行結果は以下となる。
28292829 2829 2829 3.000941
プロセスIDは全て同じで,3秒後に処理が終了しているため,今まで通り並行で処理が行われていることがわかる。
補足
今回,上記のようにFiberで並行処理を行うFiber Schedulerの詳細については調べきれなかったが,Fiberの詳細を少しだけ調べたので,補足する。
Fiberには,.yield
と#resume
というメソッドが存在する。.yield
で渡した引数が,#resume
を呼び出すと返される。#resume
は.yield
を呼び出した回数+1回呼び出すことができ,最後の#resume
では,.new
ブロックの戻り値が渡される。
具体的な実装で見てみる。
irb(main):077:1* fiber = Fiber.new do irb(main):078:1* 1 irb(main):079:1* Fiber.yield 2 irb(main):080:1* 3 irb(main):081:0> end => #<Fiber:0x000000010e1c2a78 (irb):77 (created)> irb(main):082:0> fiber.resume => 2 irb(main):083:0> fiber.resume => 3 irb(main):084:0> fiber.resume (irb):84:in `resume': attempt to resume a terminated fiber (FiberError)
fiber
にはFiber
のインスタンスであり,一回目の#resume
では,.yield
で渡された2が返される。二回目の.yield
では,.new
ブロックの戻り値が返され,三回目は.yield
が今回は一回しか呼びされてないので,エラーとなる。
また,#resume
が呼び出された時は,.yield
で渡された引数が返されるが,処理は,.yield
の直前まで行われ,次の#resume
が呼び出されると,.yield
の処理から再開される。実装を見てみる。
irb(main):091:1* fiber = Fiber.new do irb(main):092:1* puts 1 irb(main):093:1* puts Fiber.yield 2 irb(main):094:1* puts 3 irb(main):095:0> end => #<Fiber:0x000000010e021728 (irb):91 (created)> irb(main):096:0> fiber.resume 1 => 2 irb(main):097:0> fiber.resume 3 => nil
一回目の#resume
では,puts 1
が実行され返り値は,.yield
の引数である2となる。二回目の#resume
では,puts Fiber.yield 2
から処理が始まり,puts 3
まで実行される。
kaminariにPRを出してみた
概要
OSSでRuby力を高めていきたいと思っている中,初のOSS挑戦にいいIssueがないかなと探していたところ下記Issueを発見。ただ,Issueが出されたのも2021/4で特に対応もされてなさそうだし,kaminariのgem自体も,もうそんなに活発に開発されているわけではないから意味あるかなと思いつつも,コードリーディングの機会も兼ねて出してみることに。
Issue
調査内容
total_count
というメソッドは,対象のActiveRecordモデルのレコード数を返すもの。しかし,load
メソッドと一緒に呼び出すと,正しいレコード数が返って来ないことがあるというのが今回のIssueの内容。
まずは検証。
irb(main):015:0> User.count (4.8ms) SELECT COUNT(*) FROM "users" => 5 irb(main):016:0> User.page(1).padding(5) User Load (0.4ms) SELECT "users".* FROM "users" /* loading for inspect */ LIMIT ? OFFSET ? [["LIMIT", 11], ["OFFSET", 5]] => #<ActiveRecord::Relation []> irb(main):017:0> User.page(1).padding(5).total_count (0.3ms) SELECT COUNT(*) FROM "users" => 5 irb(main):018:0> User.page(1).padding(5).load User Load (0.2ms) SELECT "users".* FROM "users" LIMIT ? OFFSET ? [["LIMIT", 25], ["OFFSET", 5]] => #<ActiveRecord::Relation []> irb(main):019:0> User.page(1).padding(5).load.total_count User Load (0.3ms) SELECT "users".* FROM "users" LIMIT ? OFFSET ? [["LIMIT", 25], ["OFFSET", 5]] => 0
Issue通り, User.page(1).padding(5).total_count
では本来のユーザー数である5を返すが,load
を追加したUser.page(1).padding(5).load.total_count
では0を返してしまっている。
まずは,load
の実装から見ていく。
def load(&block) unless loaded? @records = exec_queries(&block) @loaded = true end self end
loaded
はデフォルトでfalseであるため,unless内が実行される。このload
メソッドでしていることは3つ。@records
,@loaded
インスタンス変数への代入,そして,selfつまり,load
メソッドを呼び出したインスタンス自身を返す。
最初に@records
について考えていく。exec_queries(&block)
の返り値が代入されるので,exec_queries
メソッドが少しボリューミーかつ,今回ブロック引数は渡さないため,pryで空の配列が返されることだけを確認した。
[4] pry(#<User::ActiveRecord_Relation>)> exec_queries => []
@loaded
は実装通り,falseの値が代入される。
次に,total_count
の実装を見ていく。
def total_count(column_name = :all, _options = nil) #:nodoc: return @total_count if defined?(@total_count) && @total_count # There are some cases that total count can be deduced from loaded records if loaded? # Total count has to be 0 if loaded records are 0 return @total_count = 0 if (current_page == 1) && @records.empty? # Total count is calculable at the last page return @total_count = (current_page - 1) * limit_value + @records.length if @records.any? && (@records.length < limit_value) end # #count overrides the #select which could include generated columns referenced in #order, so skip #order here, where it's irrelevant to the result anyway c = except(:offset, :limit, :order) # Remove includes only if they are irrelevant c = c.except(:includes) unless references_eager_loaded_tables? c = c.limit(max_pages * limit_value) if max_pages && max_pages.respond_to?(:*) # .group returns an OrderedHash that responds to #count c = c.count(column_name) @total_count = if c.is_a?(Hash) || c.is_a?(ActiveSupport::OrderedHash) c.count elsif c.respond_to? :count c.count(column_name) else c end end
まずは,一行目。
return @total_count if defined?(@total_count) && @total_count
@total_count
というインスタンス変数にキャッシュしていて,キャッシュが存在すればその値を返し,なけれが後続の処理が走る。
次の処理を見ていく。
if loaded? # Total count has to be 0 if loaded records are 0 return @total_count = 0 if (current_page == 1) && @records.empty? # Total count is calculable at the last page return @total_count = (current_page - 1) * limit_value + @records.length if @records.any? && (@records.length < limit_value) end
先ほどのload
メソッドを実行していると,loaded?
がtrueとなり,if文の中が実行されるため,if文の処理を追っていく。
まずは,一つ目の処理を見る。
return @total_count = 0 if (current_page == 1) && @records.empty?
&&の二つ目の@records.empty?
に関しては,先ほどのload
メソッドで@records
には空の配列が代入されることを確認したので,trueとなる。なので,current_page
について調べる。current_page
の実装は以下。
def current_page offset_without_padding = offset_value offset_without_padding -= @_padding if defined?(@_padding) && @_padding offset_without_padding = 0 if offset_without_padding < 0 (offset_without_padding / limit_value) + 1 rescue ZeroDivisionError raise ZeroPerPageOperation, "Current page was incalculable. Perhaps you called .per(0)?" end
1行目から読んでいく。
offset_without_padding = offset_value
offset_value
という変数が出てきた。定義ジャンプしてもジャンプできないし,定義しているところも見つからなかったが,どうやらActiveRecordのoffset
メソッドを使用したときの引数の値が代入される模様。だが,User.page(1).padding(5).load.total_count
の時を考えているから,offset
メソッドは特に呼び出していない。そこで,page
メソッドを見にいく。実装は以下。
def self.#{Kaminari.config.page_method_name}(num = nil) per_page = max_per_page && (default_per_page > max_per_page) ? max_per_page : default_per_page limit(per_page).offset(per_page * ((num = num.to_i - 1) < 0 ? 0 : num)).extending do include Kaminari::ActiveRecordRelationMethods include Kaminari::PageScopeMethods end end
Kaminari.config.page_method_name
==page
となる。limit(per_page).offset(per_page * ((num = num.to_i - 1) < 0 ? 0 : num)).extending do
確かにoffset
メソッドが呼ばれていた。引数は,per_page * ((num = num.to_i - 1) < 0 ? 0 : num)
。num
は引数で,今回は1。また,per_page
はper_page = max_per_page && (default_per_page > max_per_page) ? max_per_page : default_per_page
であり,max_page
は定義されていないため,default_page
のデフォルトが使用され,デフォルトが25となり,per_page
は25。よって,offset
の引数は0となり,offset_value
も0となる。User.page(1).padding(5).load.total_count
で検証してみる。
[1] pry(#<User::ActiveRecord_Relation>)> offset_value => 5
5となり,page
メソッドで呼ばれたoffset(0)
とは異なる結果となっている。実は,padding
メソッドでもoffset
メソッドが呼ばれており(というより,現在のページ数からの開始位置を決定するのがこのメソッドの本来の役割),以下のようになっている。
def padding(num) num = num.to_i raise ArgumentError, "padding must not be negative" if num < 0 @_padding = num offset(offset_value + @_padding) end
offset(offset_value + @_padding)
で,@_padding
は今回のpadding
メソッドの5となるため,offset(0 + 5)
が呼ばれるため,offset_value
も5となる。
それでは,current_page
メソッドの続きに戻る。current_page
メソッドを再掲する。
def current_page offset_without_padding = offset_value offset_without_padding -= @_padding if defined?(@_padding) && @_padding offset_without_padding = 0 if offset_without_padding < 0 (offset_without_padding / limit_value) + 1 rescue ZeroDivisionError raise ZeroPerPageOperation, "Current page was incalculable. Perhaps you called .per(0)?" end
改めて処理を追っていく。
offset_without_padding = offset_value
offset_value
は5なので,offset_without_padding
には5が代入される。
offset_without_padding -= @_padding if defined?(@_padding) && @_padding
先ほどのpadding(5)
メソッドにより,@_padding
には5が代入されているので,offset_without_padding
は0。
offset_without_padding = 0 if offset_without_padding < 0
offset_without_padding
は0なので,無視。
(offset_without_padding / limit_value) + 1
offset_without_padding
は0なので,current_page
は1となる。limit_value
は,ActiveRecordのlimit
メソッドの引数となるみたいだが,offset_value
メソッドと同様にpage
メソッドでlimit
メソッドが呼ばれている。以下,実装。
limit(per_page).offset(per_page * ((num = num.to_i - 1) < 0 ? 0 : num)).extending do
per_page
は先ほど見たようにdefault_per_page
が適用されているため,limit(25)
が呼ばれている。よって,limit_value
は25。ここまでで,current_page
の実装。
本題のtotal_count
メソッドに戻る。先ほどまで見ていた箇所は以下。
return @total_count = 0 if (current_page == 1) && @records.empty?
@records.empty?
はtrueとなり,current_page
は1であるため,ifはtrueとなり,@total_count
が0となり,早期リターンされてしまう。ここが今回のバグの原因な模様。
次に,今回のIssueと同じ状況で考える。User.page(1).per(2).padding(4).load.total_count
を考える。実際にメソッドを呼び出してみる。
[11] pry(#<User::ActiveRecord_Relation>)> User.count => 5 [12] pry(#<User::ActiveRecord_Relation>)> User.page(1).per(2).padding(4).total_count => 5 [13] pry(#<User::ActiveRecord_Relation>)> User.page(1).per(2).padding(4).load.total_count => 1
確かにIssueと同じ挙動となる。先ほどと同じようにtotal_count
メソッドを読んでいく。
return @total_count = 0 if (current_page == 1) && @records.empty?
pryでcurrent_page
の値を確認する。
[6] pry(#<User::ActiveRecord_Relation>)> current_page => 1
current_page
は前回と同じ1である。1ページあたりデータ数が2つで,5個目のデータから始め,1ページ目を表示するため,当たり前の結果ではあるが。
次に,@records
の中身をpryで確認する。
[7] pry(#<User::ActiveRecord_Relation>)> User.count => 5 [8] pry(#<User::ActiveRecord_Relation>)> @records => [#<User:0x00007f8ff8072f80 id: 5, name: "user_name5", avatar_url: "avatar5", created_at: Sat, 14 May 2022 13:02:25.931165000 UTC +00:00, updated_at: Sat, 14 May 2022 13:02:25.931165000 UTC +00:00>]
@records
がnilではなくなっている。そのため,(current_page == 1) && @records.empty?
はfalseとなり,1つ目の例の早期リターンの処理は行われない。@records
への代入には,load
メソッド内で,exec_queries
の返り値が使われている。先ほど,空の配列を常時返すというのは間違い。exec_queries
の実装を読んでいきたいところだが,少し重そうなため,今回はパス。ただ,先ほどの例と今回の例を見た感じload
メソッドを呼び出すインスタンスが返ってきそう。
一つ目の早期リターンが実行されないので,二つ目の処理を確認する。実装は以下。
return @total_count = (current_page - 1) * limit_value + @records.length if @records.any? && (@records.length < limit_value)
@records.any?
に関しては,先ほど@records
が存在することが確認できたので,trueが返る。また,current_page
において,limit_value
やoffset_value
が使われるため,それらを再定義しているper
メソッドの実装を追っていく。
def per(num, max_per_page: nil) max_per_page ||= ((defined?(@_max_per_page) && @_max_per_page) || self.max_per_page) @_per = (num || default_per_page).to_i if (n = num.to_i) < 0 || !(/^\d/ =~ num.to_s) self elsif n.zero? limit(n) elsif max_per_page && (max_per_page < n) limit(max_per_page).offset(offset_value / limit_value * max_per_page) else limit(n).offset(offset_value / limit_value * n) end end
まずは一行目から。
max_per_page ||= ((defined?(@_max_per_page) && @_max_per_page) || self.max_per_page)
@max_per_page
は定義されていないため,nilとなる。
@_per = (num || default_per_page).to_i
@_per
には,引数で渡しているnum=2
が入る。
次に,条件分岐を確認していく。
まずは,一つ目の/^\d/ =~ num.to_s
。^\d
は任意の数字で始まる文字列を意味する正規表現なので,今回の場合はtrueとなり,この条件式は実行されない。
二つ目の,n.zero?
はn
が2であるためfalseとなり,max_per_page
も定義されていないため,else式が実行される。実行される実装は以下。
limit(n).offset(offset_value / limit_value * n)
n == 2,offset_value == 0,limit_value == 25
となるので,実際に実行されるのは,limit(2).offset(0)
となり,limit_value == 2,offset_value == 0
となる。
次に,padding(4)
メソッドが実行されるので,その実行内容を前回同様に簡単に確認する。
def padding(num) num = num.to_i raise ArgumentError, "padding must not be negative" if num < 0 @_padding = num offset(offset_value + @_padding) end
@_padding=4
であるため,offset(0 + 4)
が実行され,offset_value=4
となる。では,最後に本題である,total_count
メソッドに戻る。
return @total_count = (current_page - 1) * limit_value + @records.length if @records.any? && (@records.length < limit_value)
@records.length == 1, limit_value == 2
となり,この早期リターンが実行される。まずは,current_page
の値を実装から確認する。
def current_page offset_without_padding = offset_value offset_without_padding -= @_padding if defined?(@_padding) && @_padding offset_without_padding = 0 if offset_without_padding < 0 (offset_without_padding / limit_value) + 1 rescue ZeroDivisionError raise ZeroPerPageOperation, "Current page was incalculable. Perhaps you called .per(0)?" end
offset_value == 4, @padding == 4
であり,offset_without_padding == 0
となるため,current_page == 1
となり,二つ目の例の冒頭で述べた結果と一致する。そこで,total_count
メソッドの実装に戻る。
return @total_count = (current_page - 1) * limit_value + @records.length if @records.any? && (@records.length < limit_value)
current_page == 1
であるため,(current_page - 1) * limit_value
の項は0となるため,total_count
の返り値は@records.length
つまり1となり,冒頭及びIssueの内容と一致する。
対応
ここまで今回のバグがなぜ起こっているのかを確認してきたわけだが,具体的な対応方法について検討する。
最初はloaded?
の条件分岐を消せば,毎回SQLを叩きにいって解決とか安易に考えていたが,それだとSQLを叩かずに計算できる時がもったいない。ならば,今回の問題としては,padding
メソッドが実行された時に,total_count
の挙動が上記で調査したようにおかしくなってしまうので,padding
メソッドが使用された時には,loaded?
の条件分岐で行われていた計算を行わないようにすれば良い。
padding
メソッドが使用された時,@_padding
というインスタンス変数が定義され,このメソッドのみで@_padding
への代入が行われていたので,以下のように条件を増やすという対応が適切だと思った。
if loaded? && !@_padding