Mongoid でトランザクションを試す

MongoDB 4.x はトランザクションをサポートしている。

巷には様々なプログラミング言語から MongoDB のトランザクションを試す記事が上がっているが、 Mongoid も例にもれずトランザクションに対応しているので、これを試してみる。

# MongoDB のトランザクション

詳しいことは [公式ドキュメント](https://docs.mongodb.com/manual/core/transactions/) を参照してもらうのが良い。
MongoDB のトランザクションは以下のような事柄に対応しており、この記事ではそれらについて試す。

1. 1件のドキュメントをコミット
1. ロールバック
1. 複数ドキュメントを一括で処理
1. 異なるコレクションのドキュメントを一括で処理

# 1. 1件のドキュメントをコミット

まずは rails console でトランザクション内で1件のドキュメントを作成してコミットしてみる。

```rb
irb(main):001:0> Item.with_session do |s|
irb(main):002:1* s.start_transaction
irb(main):003:1> Item.create(name: 'item01')
irb(main):004:1> s.commit_transaction
irb(main):005:1> end
MONGODB | [9] mongo:30002 #1 | sandbox.insert | STARTED | {"insert"=>"items", "ordered"=>true, "startTransaction"=>true, "autocommit"=>false, "documents"=>[{"_id"=>BSON::ObjectId('5df8f7f921b6200c6571f5af'), "name"=>"item01"}], "txnNumber"=>#<BSON::Int64:0x00007fd8e229e768 @value=1>, "lsid"=>{"id"=><BSON::Bi...
MONGODB | [9] mongo:30002 | sandbox.insert | SUCCEEDED | 0.001s
MONGODB | [10] mongo:30002 #1 | admin.commitTransaction | STARTED | {"commitTransaction"=>1, "autocommit"=>false, "$clusterTime"=>{"clusterTime"=>#<BSON::Timestamp:0x00007fd8e2350620 @seconds=1576597490, @increment=2>, "signature"=>{"hash"=><BSON::Binary:0x70284742394300 type=generic data=0x0000000000000000...>, "keyI...
MONGODB | [10] mongo:30002 | admin.commitTransaction | SUCCEEDED | 0.001s
=> true
```

`with_session` のブロックが完了した時点で処理が走る。
デフォルトではトランザクションは60秒でタイムアウトするので、 `start_transaction` と `commit_transaction` の間に sleep を入れると違う結果が得られる。

```rb
irb(main):006:0> Item.with_session do |s|
irb(main):007:1* s.start_transaction
irb(main):008:1> Item.create(name: 'item02')
irb(main):009:1> sleep(90)
irb(main):010:1> s.commit_transaction
irb(main):011:1> end
MONGODB | [11] mongo:30002 #1 | sandbox.insert | STARTED | {"insert"=>"items", "ordered"=>true, "startTransaction"=>true, "autocommit"=>false, "documents"=>[{"_id"=>BSON::ObjectId('5df8fb5221b6200c6571f5b3'), "name"=>"item02"}], "$clusterTime"=>{"clusterTime"=>#<BSON::Timestamp:0x00007fd8f2ac52d8 @seconds=157...
MONGODB | [11] mongo:30002 | sandbox.insert | SUCCEEDED | 0.002s
MONGODB | [12] mongo:30002 #1 | admin.commitTransaction | STARTED | {"commitTransaction"=>1, "autocommit"=>false, "$clusterTime"=>{"clusterTime"=>#<BSON::Timestamp:0x00007fd8f2b96220 @seconds=1576598346, @increment=1>, "signature"=>{"hash"=><BSON::Binary:0x70284880949340 type=generic data=0x0000000000000000...>, "keyI...
MONGODB | [12] mongo:30002 | admin.commitTransaction | FAILED | Transaction 2 has been aborted. (251) | 0.004872s
Traceback (most recent call last):
        2: from (irb):23
        1: from (irb):27:in `block in irb_binding'
Mongo::Error::OperationFailure (Transaction 5 has been aborted. (251) (on mongo:30002, attempt 1))
```

# 2. ロールバック

`abort_transaction` すればロールバックできる。

```rb
irb(main):001:0> Item.with_session do |s|
irb(main):002:1* s.start_transaction
irb(main):003:1> Item.create(name: 'item03')
irb(main):004:1> s.abort_transaction
irb(main):005:1> end
MONGODB | [9] mongo:30002 #1 | sandbox.insert | STARTED | {"insert"=>"items", "ordered"=>true, "startTransaction"=>true, "autocommit"=>false, "documents"=>[{"_id"=>BSON::ObjectId('5df8fd3f21b6204af471f5af'), "name"=>"item03"}], "txnNumber"=>#<BSON::Int64:0x00007fd8f2757858 @value=1>, "lsid"=>{"id"=><BSON::Bi...
MONGODB | [9] mongo:30002 | sandbox.insert | SUCCEEDED | 0.001s
MONGODB | [10] mongo:30002 #1 | admin.abortTransaction | STARTED | {"abortTransaction"=>1, "autocommit"=>false, "$clusterTime"=>{"clusterTime"=>#<BSON::Timestamp:0x00007fd8c20af2d8 @seconds=1576598846, @increment=1>, "signature"=>{"hash"=><BSON::Binary:0x70284472580280 type=generic data=0x0000000000000000...>, "keyId...
MONGODB | [10] mongo:30002 | admin.abortTransaction | SUCCEEDED | 0.002s
=> true
irb(main):006:0> Item.where(name: 'item03').first
MONGODB | [11] mongo:30002 #1 | sandbox.find | STARTED | {"find"=>"items", "filter"=>{"name"=>"item03"}, "sort"=>{"_id"=>1}, "limit"=>1, "singleBatch"=>true, "$clusterTime"=>{"clusterTime"=>#<BSON::Timestamp:0x00007fd8c20af2d8 @seconds=1576598846, @increment=1>, "signature"=>{"hash"=><BSON::Binary:0x7028447...
MONGODB | [11] mongo:30002 | sandbox.find | SUCCEEDED | 0.002s
=> nil
```

# 3. 複数ドキュメントを一括で処理

トランザクションが機能していることを確認するために `Mongoid::Timestamps` を使う。

```rb:app/models/item.rb
class Item
  include Mongoid::Document
  include Mongoid::Timestamps

  field :name
end
```

2件のドキュメントを連続して作成してみる（そして間に sleep を挟む）。

```rb
irb(main):001:0> Item.with_session do |s|
irb(main):002:1* s.start_transaction
irb(main):003:1> Item.create(name: 'item01')
irb(main):004:1> sleep(10)
irb(main):005:1> Item.create(name: 'item02')
irb(main):006:1> s.commit_transaction
irb(main):007:1> end
MONGODB | [9] mongo:30002 #1 | sandbox.insert | STARTED | {"insert"=>"items", "ordered"=>true, "startTransaction"=>true, "autocommit"=>false, "documents"=>[{"_id"=>BSON::ObjectId('5df9fd3721b620cf5d71f5af'), "name"=>"item01", "updated_at"=>2019-12-18 10:19:35 UTC, "created_at"=>2019-12-18 10:19:35 UTC}], "tx...
MONGODB | [9] mongo:30002 | sandbox.insert | SUCCEEDED | 0.002s
MONGODB | [10] mongo:30002 #1 | sandbox.insert | STARTED | {"insert"=>"items", "ordered"=>true, "autocommit"=>false, "documents"=>[{"_id"=>BSON::ObjectId('5df9fd4121b620cf5d71f5b0'), "name"=>"item02", "updated_at"=>2019-12-18 10:19:45 UTC, "created_at"=>2019-12-18 10:19:45 UTC}], "$clusterTime"=>{"clusterTime...
MONGODB | [10] mongo:30002 | sandbox.insert | SUCCEEDED | 0.002s
MONGODB | [11] mongo:30002 #1 | admin.commitTransaction | STARTED | {"commitTransaction"=>1, "autocommit"=>false, "$clusterTime"=>{"clusterTime"=>#<BSON::Timestamp:0x00007fd8d24b0520 @seconds=1576664379, @increment=1>, "signature"=>{"hash"=><BSON::Binary:0x70284608897460 type=generic data=0x0000000000000000...>, "keyI...
MONGODB | [11] mongo:30002 | admin.commitTransaction | SUCCEEDED | 0.002s
=> true
```

そして上記を実行してから mongo shell でドキュメントを確認する。

```js
mongo-set:PRIMARY> new Date()
ISODate("2019-12-18T10:19:39.393Z")
mongo-set:PRIMARY> db.items.find()
mongo-set:PRIMARY> db.items.find()
{ "_id" : ObjectId("5df9fd3721b620cf5d71f5af"), "name" : "item01", "updated_at" : ISODate("2019-12-18T10:19:35.398Z"), "created_at" : ISODate("2019-12-18T10:19:35.398Z") }
{ "_id" : ObjectId("5df9fd4121b620cf5d71f5b0"), "name" : "item02", "updated_at" : ISODate("2019-12-18T10:19:45.411Z"), "created_at" : ISODate("2019-12-18T10:19:45.411Z") }
```

1件目の `created_at` よりも後に1回目の `db.items.find()` を実行しているが、この時点では1件も保存されていないことがわかる。

# 4. 異なるコレクションのドキュメントを一括で処理

もう1つモデルを用意し、異なるコレクションをまとめて処理するケースを試す。

```rb:app/models/owner.rb
class Owner
  include Mongoid::Document
  include Mongoid::Timestamps

  field :name
end
```

Item モデルと Owner モデルの間にリレーションは存在しない。
先ほどと同じ手順で実行する。

```rb
irb(main):020:0> Item.with_session do |s|
irb(main):021:1* s.start_transaction
irb(main):022:1> Owner.create(name: 'test owner')
irb(main):023:1> sleep(20)
irb(main):024:1> Item.create(name: 'item03')
irb(main):025:1> s.commit_transaction
irb(main):026:1> end
MONGODB | [14] mongo:30002 #1 | sandbox.insert | STARTED | {"insert"=>"owners", "ordered"=>true, "startTransaction"=>true, "autocommit"=>false, "documents"=>[{"_id"=>BSON::ObjectId('5df9ff8021b620cf5d71f5b2'), "name"=>"test owner", "updated_at"=>2019-12-18 10:29:20 UTC, "created_at"=>2019-12-18 10:29:20 UTC}]...
MONGODB | [14] mongo:30002 | sandbox.insert | SUCCEEDED | 0.002s
MONGODB | [15] mongo:30002 #1 | sandbox.insert | STARTED | {"insert"=>"items", "ordered"=>true, "autocommit"=>false, "documents"=>[{"_id"=>BSON::ObjectId('5df9ff9421b620cf5d71f5b3'), "name"=>"item03", "updated_at"=>2019-12-18 10:29:40 UTC, "created_at"=>2019-12-18 10:29:40 UTC}], "$clusterTime"=>{"clusterTime...
MONGODB | [15] mongo:30002 | sandbox.insert | SUCCEEDED | 0.002s
MONGODB | [16] mongo:30002 #1 | admin.commitTransaction | STARTED | {"commitTransaction"=>1, "autocommit"=>false, "$clusterTime"=>{"clusterTime"=>#<BSON::Timestamp:0x00007fd8d23f1dc8 @seconds=1576664979, @increment=1>, "signature"=>{"hash"=><BSON::Binary:0x70284608507340 type=generic data=0x0000000000000000...>, "keyI...
MONGODB | [16] mongo:30002 | admin.commitTransaction | SUCCEEDED | 0.002s
=> true

```

上記を実行してから現在時刻および両コレクションの値を取得する。

```js
mongo-set:PRIMARY> new Date()
ISODate("2019-12-18T10:29:25.613Z")
mongo-set:PRIMARY> db.items.find()
{ "_id" : ObjectId("5df9fd3721b620cf5d71f5af"), "name" : "item01", "updated_at" : ISODate("2019-12-18T10:19:35.398Z"), "created_at" : ISODate("2019-12-18T10:19:35.398Z") }
{ "_id" : ObjectId("5df9fd4121b620cf5d71f5b0"), "name" : "item02", "updated_at" : ISODate("2019-12-18T10:19:45.411Z"), "created_at" : ISODate("2019-12-18T10:19:45.411Z") }
mongo-set:PRIMARY> db.owners.find()
mongo-set:PRIMARY>
mongo-set:PRIMARY> db.items.find()
{ "_id" : ObjectId("5df9fd3721b620cf5d71f5af"), "name" : "item01", "updated_at" : ISODate("2019-12-18T10:19:35.398Z"), "created_at" : ISODate("2019-12-18T10:19:35.398Z") }
{ "_id" : ObjectId("5df9fd4121b620cf5d71f5b0"), "name" : "item02", "updated_at" : ISODate("2019-12-18T10:19:45.411Z"), "created_at" : ISODate("2019-12-18T10:19:45.411Z") }
{ "_id" : ObjectId("5df9ff9421b620cf5d71f5b3"), "name" : "item03", "updated_at" : ISODate("2019-12-18T10:29:40.162Z"), "created_at" : ISODate("2019-12-18T10:29:40.162Z") }
mongo-set:PRIMARY> db.owners.find()
{ "_id" : ObjectId("5df9ff8021b620cf5d71f5b2"), "name" : "test owner", "updated_at" : ISODate("2019-12-18T10:29:20.158Z"), "created_at" : ISODate("2019-12-18T10:29:20.158Z") }
```

トランザクションの一連の処理が完了するまでは2件とも書き込まれていないことがわかる。
また owners への create が mongo shell での現在時刻の確認よりも前であることもわかる。