Laravel5.5で排他制御のテストをする


仕事でどうしてもDBトランザクションでまかなえない排他制御を行う必要が出てきたので。

普段あまりテストを書くほうではないのですが、検証環境で並行処理の競合状態なんてそうそう発生しないのでさすがにテストを書かないとまずいと思い勉強したものです。

関連ソースはこのあたり。
https://gist.github.com/kuinaein/ad4b6167f712f4035a8f31a16e13c905

テスト対象メソッドの概要

malkusch\lockのラッパーです。内部でFlockMutexを利用しています。
$timeoutの単位は「秒」です。

class ConcurrentUtil {
  public static function synchronized(string $key, \Closure $closure, int $timeout = 10) {
    // ...
  }
}

とりあえずテストケース作成

Laravelプロジェクトに最初からついているPHPUnitを使います。

$ php artisan make:test --unit 'Util\ConcurrentUtilTest'

まずは普通に実行。

tests\Unit\Util\ConcurrentUtilTest.php
public function testSync() {
  $i = 100;
  ConcurrentUtil::synchronized('test.sync', function() use (&$i) {
    $i += 100;
  });
  $this->assertEquals($i, 200);
}
$ vendor/bin/phpunit

...
OK (3 tests, 3 assertions)

競合状態をシミュレートしてテスト

Javaなんかだと簡単にスレッドを生成できるのですが、PHPでスレッドを使おうとすると別途pthreads等をセットアップする必要があり結構骨が折れます。。プロセス生成コストが気になるような処理をPHPでやるなってことですね分かります。

なのでプロセスを複数動かして同期を取らせることにします。
幸いなことにLaravelにはコンソールコマンドを簡単に作る機能があり、しかもこいつはユーティリティクラスはおろか本体のモデルクラス等も呼び出せます。今回はこれを流用することにします。

routes/console.php
// 引数で与えられたファイルの内容をインクリメントするだけ
Artisan::command('testOnly:concurrentWrite {path}', function($path) {
  ConcurrentUtil::synchronized('test.concurrentWrite', function() use($path) {
    $n = 1;
    if (Storage::exists($path)) {
      $n += (int)Storage::get($path);
    }
    sleep(1);
    Storage::put($path, $n);
  });
});

テストケースではproc_*()を呼んで上記のコマンドを4回同時に実行します。

tests\Unit\Util\ConcurrentUtilTest.php
public function testSyncQuadruple() {
  $path = 'test/test.txt';
  if (Storage::exists($path)) {
    Storage::delete($path);
  }
  $cmd = sprintf('php %s testOnly:concurrentWrite %s', base_path('artisan'), $path);
  $procs = [];
  try {
    for ($i = 0; $i < 4; ++$i) {
      $pipes = [];
      $procs[] = proc_open($cmd,
          [['pipe', 'r'], ['pipe', 'w'], ['pipe', 'w']], $pipes);
    }
    for (;;) {
      foreach ($procs as $p) {
        if (proc_get_status($p)['running']) {
          sleep(1);
          continue 2;
        }
      }
      break;
    }
    $this->assertEquals(Storage::get($path), '4');
    Storage::delete($path);
  } finally {
    foreach($procs as $p) {
      @proc_close($p);
    }
  }
}

タイムアウトのテスト

PHPUnitでも他のxUnitと同様「例外が投げられた場合に成功」という条件が付けられます。phpDocumentorコメントで@expectedExceptionを付けるだけです。
コード中で例外クラスを使用していないせいか(?)、use宣言は無視されたので完全修飾名を指定しています。

tests\Unit\Util\ConcurrentUtilTest.php
/**
  * @test
  * @expectedException \malkusch\lock\exception\TimeoutException
  */
public function testTimeout() {
  ConcurrentUtil::synchronized('test.timeout', function() {
    ConcurrentUtil::synchronized('test.timeout', function() {
      $path = app_path();
    }, 3);
  });

上記テストが成功するので、FlockMutexは同一スレッドから同じミューテックスを獲得しようとするデッドロックすることが分かります。DBトランザクション系のMutex実装だと再入可能になるんだろうか。

なお、このテストケースを書いたおかげで、FlockMutexコンストラクタの第2引数に1未満の小数を指定するとpcntl拡張が有効な環境ではタイムアウトしなくなることが分かりました。やっぱりテストは大切ですね。。