Engineering from Scratch

エンジニア目指してます

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は,参照されていないメモリを見つけ,解放することを行う。

手順

  1. メモリに,デフォルトで0(false)のマークをつける。そして,参照できるメモリには深さ優先探索により1(true)のマークを付ける。
  2. 参照されていないメモリ領域(falseマークのついている領域)を解放し,参照されているメモリ領域のマークをtrueからfalseに変更する。

メリット

  • 循環参照の場合でも無限ループにならない
  • アルゴリズムの処理中に余分なオーバヘッドが発生しない

デメリット

  • Garbage Collectionの処理が実行中の時,メインプログラムが中断される
  • Mark and Sweepアルゴリズムが複数回実行された後,使用されているメモリ空間が多くの小さな領域に分割されてしまう(フラグメンテーション

参考

www.geeksforgeeks.org

参考

workingwithruby.com

2022/06/02

プロセスとスレッド

MMU

  • Memory Management Unit
  • プロセスごとに専用の物理メモリ領域を確保し,その物理領域にアクセスするための仮想アドレスを用意し,両者のアドレスのマッピングを行う
    • マッピング情報はページテーブルという名前でメモリ上に保持される

プロセスがメモリに保持している情報

  • テキストセグメント
    • プログラムの命令列
  • データセグメント
    • PDA(Processor Data Area)
      • スタックポイント
        • スタック領域のどこを見ているかを指す
      • プログラムカウンタ
        • プログラムのどこを実行しているかを指す
    • データ領域
    • スタック領域
      • 引数やローカルスコープのデータ

プロセスとスレッドの違い

1つのプログラムをプロセスとスレッドで並列に処理を行う時を考える。

プロセス

テキストセグメントは親と子プロセスで共有し,データセグメントは親と子プロセスで違う領域を持つ。

スレッド

ほぼ全てのデータをスレッドと共有する。

パフォーマンスの違い

プロセスの場合

プロセスごとに固有のメモリ領域を持つため,プロセスを切り替える場合は,仮想アドレスと物理アドレスマッピングをそのプロセス用のものへと切り替える必要がある。

1度アドレスを解決した結果はMMUがキャッシュしているため,プロセスを切り替えるごとにMMUが持っているキャッシュをクリアする必要がある(TLBフラッシュ)。TLBフラッシュ直後は全てのキャッシュが消えるので,コストが大きい。

スレッドの場合

スレッドごとに共通のメモリ領域を持つため,利用する物理アドレスと仮想アドレスのマッピング情報を変える必要はなく,TLBフラッシュも起こらない。

参考

moro-archive.hatenablog.com

Unix Processes

System Calls

Kernelはハードウェアでの処理を仲介する。直接Kernelにアクセスすることはなく,system callを通してアクセスできる。

Processes Have Parents

システム上のプロセスにはその親プロセスが存在する。

参考

workingwithruby.com

2022/06/01

PumaとUnicorn

Unicorn

  • blocking
  • マルチプロセス
  • forkを使ったmaster-slave
      • unicorn-masterが起動するとunicorn-childをforkして生産する。暇になった,unicorn-childがunicorn-masterにリクエストを要求する。masterは要求のあったchildに処理を行わせるだけでよく,重い処理を行っているchildが処理を担当することがなくなる
    • メモリ効率がいい
    • プロセスが死ぬわけでないので,ダウンタイムが発生しない
    • デプロイが早くダウンタイムが発生しない
  • スロークライアント(リクエストが遅いクライアント)に弱い
    • スロークライアントが来た場合,workerが待ちのまま止まってしまう
    • リバースプロキシを挟んで,スロークライアントをバッファしてもらう必要がある

Puma

  • マルチプロセス
  • マルチスレッド
    • I/O waitが発生したときに,別スレッドの処理を進められる
    • スレッドセーフな実装をする必要がある
  • スロークライアントが来ても1つのスレッドが埋まるだけ

参考

blog.kasei-san.com

blog.willnet.in

techracho.bpsinc.jp

qiita.com

2022/05/31

NginxとApache

参考

qiita.com

blog.inductor.me

blog.framinal.life

C10K問題とは

ハードウェアの性能上は問題がなくても,あまりにもクライアントの数が多くなるとサーバーがパンクする問題のこと。プロセスやスレッドが多くなることが原因となる。

プロセスやスレッドが多くなると

Apache

preforkモデル

  • 1プロセスあたり1スレッド
  • プロセス数の上限に達しやすくなる

    workerモデル

  • 1プロセスあたり複数スレッド
  • ファイルディスクリプタの上限(1プロセスあたり1024)に達しやすくなる

Nginx

概要

1プロセス1スレッドで複数のアクセスを捌ける。

特徴

  • ノンブロッキングI/O
  • イベント駆動
    • クライアントのアクセスをイベントとして扱い,それをきっかけにプロセス内で処理を開始する

ノンブロッキングI/Oとは

参考

blog.takanabe.tokyo

ブロッキング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 ミニ版

www.oreilly.co.jp

メモ

チャンク

概要

全体を一括で送信するのではなく,小分けにして送信する。時間のかかるデータ転送を少しずつ前倒しで行うことができる。

メリット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を出してみた

概要

OSSRuby力を高めていきたいと思っている中,初のOSS挑戦にいいIssueがないかなと探していたところ下記Issueを発見。ただ,Issueが出されたのも2021/4で特に対応もされてなさそうだし,kaminariのgem自体も,もうそんなに活発に開発されているわけではないから意味あるかなと思いつつも,コードリーディングの機会も兼ねて出してみることに。

Issue

github.com

調査内容

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という変数が出てきた。定義ジャンプしてもジャンプできないし,定義しているところも見つからなかったが,どうやらActiveRecordoffsetメソッドを使用したときの引数の値が代入される模様。だが,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_pageper_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は,ActiveRecordlimitメソッドの引数となるみたいだが,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>]

@recordsnilではなくなっている。そのため,(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_valueoffset_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

出したPR

https://github.com/kaminari/kaminari/pull/1085