CakePHP1.3.20、2.5.3で修正されたデータが全件更新されるやつを調べてみた

http://bakery.cakephp.org/articles/markstory/2014/07/21/cakephp_2_5_3_and_1_3_20_released

7/21に久々に1.3系を含むCakeのアップデートがリリースされました。
1.3系も更新されるやつは大体やばいやつ、ということで今回も何やらやばそうな事が書いてあります。

The 1.3.20 release contains an important fix to address a potential race condition in Model::save() that can cause data loss when records are deleted during concurrent updates.

data lossとはどういうことか、実際に試して確認してみました。

結論

save(update)処理の最中に対象レコードが削除されると、〜where1=1というえらいクエリが発行されて更新しようとした内容で全件updateが発生します。
速やかにバージョンアップしましょう。1.3系は他に変更点はなさそうです。

環境

PHP5.4.31
CakePHP1.3.19(再現環境)

試してみる

system_logsというテーブルに対してID指定で更新をかける下記のようなコードを実行。

$this->SystemLog->id = 100;
$updatedata = [];
$updatedata['SystemLog']['user_id'] = 1;
$this->SystemLog->save($updatedata);

id=100のレコードが存在する場合

下記のようなクエリが実行される。


SELECT COUNT(*) AS `count` FROM `system_logs` AS `SystemLog`   WHERE `SystemLog`.`id` = 100

SELECT COUNT(*) AS `count` FROM `system_logs` AS `SystemLog`   WHERE `SystemLog`.`id` = 100

UPDATE `system_logs` SET `user_id` = 1  WHERE `system_logs`.`id` = 100

カウントが二回実行されてますがどちらも存在チェック(model::exits())で呼ばれてます。
普通にアップデートされてますね。

id=100のレコードが存在しない場合

SELECT COUNT(*) AS `count` FROM `system_logs` AS `SystemLog`   WHERE `SystemLog`.`id` = 100

INSERT INTO `system_logs` (`user_id`, `created`) VALUES (1, '2014-08-07 19:00:29')

最初の存在チェックでレコードが存在しないため、insertが実行されました。

id=100のレコードが更新途中で削除された場合

初回の存在チェックと、二回目の存在チェック(DboSource::defaultConditions)より前にsleepを入れてレコード削除。

SELECT COUNT(*) AS `count` FROM `system_logs` AS `SystemLog`   WHERE `SystemLog`.`id` = 100

SELECT COUNT(*) AS `count` FROM `system_logs` AS `SystemLog`   WHERE `SystemLog`.`id` = 100

UPDATE `system_logs` SET `user_id` = 1  WHERE 1 = 1

WHERE1=1というやばそうなUPDATEが実行されました。

何が起きているのか?

ID指定ありで、Modell::save内の初回の存在チェックが通るとupdateとして処理が行われるのですが、その際のcondition(WHERE句)作成ロジック内で再度対象レコードの存在チェックが行われ、対象レコードが存在しないとdefaultCondition=nullとして扱われ、WHERE 1 = 1というSQLが発行されるようです。

dbo_source.phpの1877行あたり

   function defaultConditions(&$model, $conditions, $useAlias = true) {
        if (!empty($conditions)) {
            return $conditions;
        }
        $exists = $model->exists();
        if (!$exists && $conditions !== null) {
            return false;
        } elseif (!$exists) {
            return null; // 存在チェックに引っかかるとnullが返り、結果的にwhere1=1となる
        }
        $alias = $model->alias;

        if (!$useAlias) {
            $alias = $this->fullTableName($model, false);
        }
        return array("{$alias}.{$model->primaryKey}" => $model->getID()); //通常はこっち
    }

アップデート版の挙動

以下のように修正されてます。
$model->__safeUpdateModeはsaveによる更新時はtrueとなります。

    function defaultConditions(&$model, $conditions, $useAlias = true) {
        if (!empty($conditions)) {
            return $conditions;
        }
        $exists = $model->exists();
        if (!$exists && ($conditions !== null || !empty($model->__safeUpdateMode))) {
            return false;
        } elseif (!$exists) {
            return null;
        }
        $alias = $model->alias;

        if (!$useAlias) {
            $alias = $this->fullTableName($model, false);
        }
        return array("{$alias}.{$model->primaryKey}" => $model->getID());
    }

存在チェックに引っかかった場合はnullではなくfalseが返るようになります。
defaultConditionsでfalseが返された場合、実行されるSQLは以下のようになります。

UPDATE `system_logs` SET `user_id` = 1  WHERE 0 = 1

お、おう、という感じはしますが結果的に更新はかからなくなります。

影響は?

同一レコードに対する更新、削除が頻繁に行われるようなシステムでなければ(例えば特定のユーザーに紐付いたデータ等であれば)そんなに起こることはなさそうですが、バッチによる一斉更新とユーザー操作がかぶったりした場合に運悪いと遭遇しそうです。
速やかにアップデートしましょう。

最後に

nullとかfalseとかarrayとかが混在して戻ってくるやつをアレコレするの見てて辛い。


Tags: ,

Leave a Reply

XHTML: You can use these tags: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <strike> <strong>