職業プログラマの休日出勤

職業プログラマによる日曜自宅プログラミングや思考実験の成果たち。リアル休日出勤が発生すると更新が滞りがちになる。記事の内容は個人の意見であり、所属している(いた)組織の意見ではない。

CakePHPの nested transaction まわりの挙動を見てみる

CakePHPでちょっとハマったのでメモ。

三行まとめ

CakePHPでの一般的なトランザクション処理

CakePHP 3.x の場合、だいたいこんな感じになります。このコードはアプリケーション内の大抵の場所から実行可能です。

$conn = ConnectionManager::get('my_connection');
$conn->begin();
$conn->execute('UPDATE articles SET published = ? WHERE id = ?', [true, 2]);
$conn->execute('UPDATE articles SET published = ? WHERE id = ?', [false, 4]);
$conn->commit();

bookからの切り貼りです。

CakePHP 2.x の場合だと、こんな感じです。これはModelのサブクラスの中で実行する場合の書き方です。

$dataSource = $this->getDataSource();
$dataSource->begin();

// Perform some tasks

if (/*all's well*/) {
    $dataSource->commit();
} else {
    $dataSource->rollback();
}

bookからの切り貼りです。

いずれの場合も、処理本体をtry-catchブロックで囲っておいて、途中で例外発生したらロールバックしてあげる、という処理も一般的ですね。

バグってるコード

慌てて書いたコードの中に、こんなものがありました。このコードは1つの HTTP Request の中で複数回呼ばれます。

$conn = ConnectionManager::get('my_connection');
try {
	$conn->begin();
	// なんか処理その1
	if(とある条件) {
		// 本当ならここに $conn->commit(); が来るはずだけどバグってる
		return;
	}
	// なんか処理その2
	$conn->commit();
}
catch(Exception $e) {
	$conn->rollback();
}

処理の意図としては、とある条件に合致したときはそこでコミットして終了して欲しいところですが、このコードではコミットし忘れています。
CakePHP 3.x の ConnectionManager::get は、同じ名前のコネクションなら(かつ、1つの HTTP Request の処理の中なら)一度接続すれば2回目以降の呼び出しについては接続済みのリソース(のラッパー)を返してきます。 2.x のModelにおける $this->getDataSource(); も同様です。なので、このバグってるコードは複数回呼ばれても1つのコネクションの使い回しになります。

さて、このバグってるコードを1リクエスト中に複数回呼び出したとき、どのような挙動になるでしょうか?
簡単のために、例外は発生しないものと仮定し、呼び出し元ではデータベース操作をしていないものとしましょう。

MySQLを使っている場合、MySQLは(savepointを使わないのなら)nested transaction はサポートされておらず、トランザクション中に start transaction や begin を実行した時点で暗黙的にコミットされます。

Transactions cannot be nested. This is a consequence of the implicit commit performed for any current transaction when you issue a START TRANSACTION statement or one of its synonyms.

https://dev.mysql.com/doc/refman/5.7/en/implicit-commit.html

このことから、バグってるコードを1リクエスト中に複数回呼び出したとき、
最後の一回を除くすべての処理はコミットされ、最後の一回がとある条件に合致したときはそこだけコミットされない、という挙動を想像することができます。

ところが、実際に動かしてみると、最初にとある条件に合致したトランザクションおよびそれ以降については、何もコミットされません。CakePHPは、MySQLで言う所のsavepointを使った nested transaction(厳密には違うのかな?)をサポートしているのです。

CakePHPのソースを読んでみる:3.x編

これは 3.4.12 の、Connection.php のソースからの引用です。
https://github.com/cakephp/cakephp/blob/e5be62241953ee90f3ec121fce3bf8074518d4be/src/Database/Connection.php

    public function begin()
    {
        if (!$this->_transactionStarted) {
            // 中略。トランザクション開始処理。
            $this->_transactionLevel = 0;
            $this->_transactionStarted = true;
            // 中略。トランザクション開始処理。
            return;
        }
        $this->_transactionLevel++;
        if ($this->isSavePointsEnabled()) {
            $this->createSavePoint($this->_transactionLevel);
        }
    }
    // 中略
    public function commit()
    {
        if (!$this->_transactionStarted) {
            return false;
        }
        if ($this->_transactionLevel === 0) {
            // 中略。コミット処理
            $this->_transactionStarted = false;
            return $this->_driver->commitTransaction();
        }
        if ($this->isSavePointsEnabled()) {
            $this->releaseSavePoint($this->_transactionLevel);
        }
        $this->_transactionLevel--;
        return true;
    }

複数回begin()を呼び出すと、_transactionLevelがインクリメントされていくことにより、同じ数だけcommit()を呼び出さないと最終的な明示的コミットが為されないことがわかります。

CakePHPのソースを読んでみる:2.x編

2.x では、DboSource.phpbegincommit を読むと良いでしょう。同じような処理があります。
このリンク先は 2.10.1 のコードです。
https://github.com/cakephp/cakephp/blob/95e0a2143934abed3baf33885032eefcf9081605/lib/Cake/Model/Datasource/DboSource.php

さいごに

自分で作ったものの間違いを100%自力で見つけるの、やっぱ難しいっす。どんなに単純なものでも。
あと、今回は「大部分のデータがコミットされない」という派手な現象が起きてたからすぐにバグってるコードの存在に行き着きましたが、これがもしも最後の1回だけコミットされていないという症状だったら、もしかしたら本番リリースするまで気付かなかったかもなぁ、という気持ちもあります。正直言うと怖いです。仕組みでなんとかしたいところです。