letsspeak's diary

世界を大いに盛り上げるためのletsspeakの日記。

初心者がFuelPHPのトランザクションに失敗した件

追記

記事を書いた後、
1.DBのクエリビルダーで更新してもダメ。
2.mysqlを直接コマンドから叩いてロールバックしてもダメ。
だったのでデータベースエンジンを確認してみたところ

mysql> show table status;
+--------------+--------+---------+------------+------+----------------+-------------+------------------+--------------+-----------+----------------+---------------------+---------------------+------------+-----------------+----------+----------------+---------+
| Name         | Engine | Version | Row_format | Rows | Avg_row_length | Data_length | Max_data_length  | Index_length | Data_free | Auto_increment | Create_time         | Update_time         | Check_time | Collation       | Checksum | Create_options | Comment |
+--------------+--------+---------+------------+------+----------------+-------------+------------------+--------------+-----------+----------------+---------------------+---------------------+------------+-----------------+----------+----------------+---------+
| click_queues | MyISAM |      10 | Fixed      |   14 |             21 |         294 | 5910974510923775 |         2048 |         0 |             15 | 2013-03-17 15:43:13 | 2013-03-18 00:47:41 | NULL       | utf8_general_ci |     NULL |                |         |
| items        | MyISAM |      10 | Dynamic    | 1099 |           2440 |     2682076 |  281474976710655 |        13312 |         0 |           1100 | 2013-03-17 15:43:13 | 2013-03-18 00:45:08 | NULL       | utf8_general_ci |     NULL |                |         |
| migration    | MyISAM |      10 | Dynamic    |    4 |             40 |         160 |  281474976710655 |         1024 |         0 |           NULL | 2013-03-17 00:28:33 | 2013-03-17 14:27:45 | NULL       | utf8_general_ci |     NULL |                |         |
| pick_counts  | MyISAM |      10 | Fixed      | 1099 |             29 |       31871 | 8162774324609023 |        13312 |         0 |           1100 | 2013-03-17 15:43:13 | 2013-03-18 00:54:38 | NULL       | utf8_general_ci |     NULL |                |         |
| sources      | MyISAM |      10 | Dynamic    |   39 |            107 |        4184 |  281474976710655 |         2048 |         0 |             47 | 2013-03-17 00:28:33 | 2013-03-17 00:28:33 | NULL       | utf8_general_ci |     NULL |                |         |
+--------------+--------+---------+------------+------+----------------+-------------+------------------+--------------+-----------+----------------+---------------------+---------------------+------------+-----------------+----------+----------------+---------+
5 rows in set (0.00 sec)

はい、どうみてもMyISAMです。トランザクションロールバックに対応していないのであたりまえの挙動でした。。

追記2

下記のようにmigrationのすべてのcreate_table時に'InnoDB'を指定することで、下記のスクリプトのままトランザクションを実現することができました!

diff --git a/fuel/app/migrations/004_create_clickqueues.php b/fuel/app/migrations/004_create_clickqueues.php
index 20f026b..dff08e5 100644
--- a/fuel/app/migrations/004_create_clickqueues.php
+++ b/fuel/app/migrations/004_create_clickqueues.php
@@ -13,7 +13,9 @@ class Create_clickqueues
      'created_at' => array('constraint' => 11, 'type' => 'int', 'null' => true),
      'updated_at' => array('constraint' => 11, 'type' => 'int', 'null' => true),

-   ), array('id'));
+    ), array('id'),
+    false, 'InnoDB', 'utf8_unicode_ci');
+
  }

  public function down()

はじめに

下記のロジックでクリック数の集計システムを作ってみました。

1.itemの一覧があって各itemのクリック時にidつきでAPIを叩く。
2.APIが叩かれたらClickQueueにitem_id, state=0を詰む。
3.一定周期でタスクclickpicker.phpを動かして集計する。

集計タスクは下記の通りです。
トランザクションを使ってClickQueueとItem(PickCount)の同期が確実の行われるように設定したつもりでした。

fuel/app/tasks/clickpicker.php
  public function run($args = NULL)
  {
    $query = \Model_ClickQueue::query()->where('state', 0); 

    while($query->count() > 0){ 

      $click_queue = $query->get_one();
      $click_queues = $query->where('item_id', $click_queue->item_id)->get();
      $click_count = count($click_queues);

      $item = \Model_Item::find($click_queue->item_id);
      $pick_count = \Model_PickCount::find($item->pick_count_id);

      $pick_count->click = $pick_count->click + $click_count;
      foreach ($click_queues as $done_click_queue)
      {   
        $done_click_queue->state = 1;
      }   

      try 
      {   
        \DB::start_transaction();
        $pick_count->save();

//        throw new \Exception('hogehoge');


        foreach ($click_queues as $done_click_queue)
        {   
          $done_click_queue->save();
        }   
        \DB::commit_transaction();

      }   
      catch (\Exception $e) 
      {   
        \DB::rollback_transaction();
        echo $e->getMessage().PHP_EOL;
        return;
      }   

      echo 'item_id = '.$item->id.'  +'.$click_count.' clicks ( total '.$pick_count->click.' clicks )'.PHP_EOL;

      $query = \Model_ClickQueue::query()->where('state', 0); 
    }   

    echo 'ClickPicker task successfully completed.'.PHP_EOL;
  }
}

実装した結果

実際に動かしてみた結果は下記の通り。

$ oil r clickpicker
item_id = 1  +1 clicks ( total 17 clicks )
ClickPicker task successfully completed.

とてもよく動いているように見えます。
が、コメントアウトしているデバッグ用の例外投げ処理を解除したうえで、item_id = 1067をクリックしてみたところ...

f:id:letsspeak:20130317235436p:plain

=> ClickQueueは未集計の状態

f:id:letsspeak:20130317235519p:plain

=> PickCountは集計された状態

となり、想定していたロールバックは正しく行われませんでした。

調査

下記内容はまだ調べている途中なので間違いがあるかもしれません!

半日かけてよくよく調べてみると、

DB::start_transaction();
DB::end_transaction();
DB::rollback_transaction();

は上記のような使い方はできないみたいです。
というのもfuel/ormは内部でPDOのインスタンスを生成しているっぽいのですが、他のorm modelとの共有機能はないみたいです。

ただfuel/orm model の機能として、

github orm/classes/mode.php

/**
	 * Save the object and it's relations, create when necessary
	 *
	 * @param  mixed  $cascade
	 *     null = use default config,
	 *     bool = force/prevent cascade,
	 *     array cascades only the relations that are in the array
	 */
	public function save($cascade = null, $use_transaction = false)

というsaveのオプションがあるようなのでcascadeを正しく設定していればオプション付きの呼び出しで自動的にtransactionが行われるように思います。

おわりに

しかし冷静に考えてみると今回のようなキュー方式の場合、ItemとClickQueueはリレーショナルな関係にはならないのでsaveのオプションを使ってトランザクションを実現するのは難しそうです。

itemの更新があまり行われない状況ならキュー方式をやめてクリックごとにPickCountを+1ずつ加算してしまうか、そのままキューを使うのであれば、素直にDB::start_transaction()と一緒にそちらのクエリビルダを利用した方が良いのかも知れません。