Help us understand the problem. What is going on with this article?

Haskell・Servant+Persistent/Esqueletoで作る実用WebAPI (7) トランザクション

More than 3 years have passed since last update.

はじめに

一つのAPIハンドラ内で、RDBに対して複数の更新系操作(insert/update/delete)を行う場合に、途中で例外が発生し(あるいは例外を投げた結果として)処理が中断してAPIエラーを返すような場合、それまでに行った更新系操作はロールバックさせる必要があります。

このネタはあまり多くの話題があるわけではないのですが、Persistentの紹介記事で「トランザクションとかロールバックはどうなんだろう?」というようなコメントを見かけるので、独立した記事として書いてみます。以下では、Persistentでのトランザクションの扱いとその制御について記載します。

Persistentでのトランザクションの取扱い

概要

Persistentのトランザクションの扱いは下記の通りです。runSql関数をベースに書いていますが、runSqlに含まれる「runSqlPool等の関数単位」と考えてもらって構いません。言うまでもないですが、Esqueletoを使った場合も事情は全く同じです。

  • runSqlブロック(doブロックの有無に関わらず)は一つのトランザクションとして扱われる
  • runSqlブロックを終了すると、自動的にコミットされる
  • runSqlブロック内で例外が発生した結果runSqlブロックを抜ける場合は、それまでのSQL処理はロールバックされる

そのため、明示的なコミットやロールバックの操作は不要です(手動でのコミット関数はエクスポートされていないはず)。要は「特に気にせずに、普通に実装していけばOK」となります。

最後の項目については、「例外が発生しても、runSqlブロック内で例外をキャッチした場合」には、ロールバックされません。これを利用した実装については、別の記事で紹介します。

ロールバックの例

実際にロールバックされる様子をデモしてみます。APIハンドラとして、「insertして正常終了するケース」と「insertの後にAPIエラーを返すケース」の2通りを試します。

insertして正常終了するケース

APIハンドラは下記の通りです。これを「GET /api/insert_ok」に紐付けます(GETメソッドを使っているのは、あくまでデモということで)

insertOkHandler :: MyAppHandler T.Text
insertOkHandler = errorHandler $ runSql $ do
  pid <- insert Person {personName = "insert_ok", personAge = Just 31, personType = PersonTypeUser}
  maybe_person <- get pid -- insert直後にselectして確認
  liftIO $ print maybe_person -- APIハンドラ内からselect結果を表示

  return "Insert OK" -- 正常終了時の戻り値

curlコマンドとその結果です。API正常終了の戻り値が返ってきています。

bash-3.2$ curl http://localhost:3000/api/insert_ok
"Insert OK"

WebAPIのコンソール(printでselect結果を出力したもの)。selectできています。

Just (Person {personName = "insert_ok", personAge = Just 31, personType = PersonTypeUser})

APIアクセス後のMySQLのカラム。insertできています。

mysql> select * from person;
+----+-----------+------+----------------+
| id | name      | age  | type           |
+----+-----------+------+----------------+
|  2 | insert_ok |   31 | PersonTypeUser |
+----+-----------+------+----------------+
1 row in set (0.01 sec)

insertの後にAPIエラーを返すケース

APIハンドラです。これを「GET /api/insert_ng」に紐付けます

insertNgHandler :: MyAppHandler T.Text
insertNgHandler = errorHandler $ runSql $ do
  pid <- insert Person {personName = "insert_ng", personAge = Just 32, personType = PersonTypeUser}
  maybe_person <- get pid -- insert直後にselectして確認
  liftIO $ print maybe_person -- APIハンドラ内からselect結果を表示

  _ <- throwM err400 {errBody = "API error test"} -- 意図的にAPIエラーを発生させる

  return "Insert OK" -- 正常終了時の戻り値(本ケースではここには来ない)

curlコマンドとその結果です。APIエラーの戻り値が返ってきています。

bash-3.2$ curl http://localhost:3000/api/insert_ng
API error test

WebAPIのコンソール(printでselect結果を出力したもの)。APIエラーを投げる前なので、selectは正常にできています。

Just (Person {personName = "insert_ng", personAge = Just 32, personType = PersonTypeUser})

APIアクセス後のMySQLのカラム。前の状態と変わっていません(insert_ng、というレコードはinsertされていない)。ロールバックが実行されています。

mysql> select * from person;
+----+-----------+------+----------------+
| id | name      | age  | type           |
+----+-----------+------+----------------+
|  2 | insert_ok |   31 | PersonTypeUser |
+----+-----------+------+----------------+
1 row in set (0.00 sec)

このように、runSqlブロック内では、一つのトランザクションとしてSQLが実行され、runSqlブロックを正常に抜け出た時にコミットされます。

runSqlブロックの使い方

パターン1:APIハンドラ全体をrunSqlブロックとする

条件としては、「RDBアクセス以外の処理に時間がかからない」「処理の途中でコミットをする必要がない」となりますが、ほとんどはこのケースにあてはまるかと思います。

hogeHandler :: PersonId -> MyAppHandler Person
hogeHandler = errorHandler $ runSql $ do
  SQL処理1
  SQL処理2
  ...

  return person -- APIの戻り値(ここまで同じインデントで同一ブロックに)

パターン2:SQL処理の部分だけrunSqlブロックとする

SQL処理の後に時間がかかる処理がある場合に、その処理の前でブロックを止めます。これはコネクションプールを不用意に専有するのを防ぎます。

hogeHandler :: PersonId -> MyAppHandler Person
hogeHandler = errorHandler $ do -- いったんここでdoブロックにする
  ret <- runSql $ do
    selectSQL処理 -- これがretに入る

  retを使った時間がかかる処理 -- runSqlと同じインデント=runSqlとは違うブロック

  return person -- APIの戻り値

パターン3:runSqlブロックを複数用いる

APIハンドラの途中までのSQLをいったんコミットしたい場合...ですが、あまりこういう使い方をしたい場面が思いつきません。

hogeHandler :: PersonId -> MyAppHandler Person
hogeHandler = errorHandler $ do
  runSql $ do
    SQL処理1
    SQL処理2 -- ここでいったんコミット
  runSql $ do
    SQL処理3
    SQL処理4 -- ここでもコミット
  ...

  return person -- APIの戻り値

まとめ

Persistentの標準動作が「runSqlのブロック単位=1つのトランザクション」となっているため、runSqlブロックをAPIハンドラ全体としておけば、特に何も考えなくてもロールバック等が望ましい動作となることがほとんどです。ただし、まれにこういったトランザクションの扱いを考えないといけない場面もあったりします。

cyclone_t
IoTコンサル勤務。Haskellやってます。
https://twitter.com/cyclone_tr
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away