Node.js+Sequelizeで楽観的ロックを使って動作を確認する

[PONOS Advent Calendar 2020](https://qiita.com/advent-calendar/2020/ponos)の8日目の記事です。

昨日は[@e73ryo](https://qiita.com/e73ryo)さんでした。

# はじめに
前回に続いてまたSequelizeの話です。

データの整合性をたもつための排他制御として代表的なものとして悲観的ロックと楽観的ロックというアプローチがあります。
それ自体については沢山記事がありますのでここでは言及致しませんが、基本的に自身のデータしか操作しないようなケースにおいては、操作が競合する可能性が低いため、実現が簡単で不具合の生みにくい楽観的ロックを採用するケースが多いかなと思います。

ということでSequelizeで楽観的ロックを利用する方法をメモ的に書いておきたいと思います。
（といってもめっちゃ簡単な話なんですが）

# この記事の対象者
Node.jsとSequelizeの基本的な知識があることを前提とします。
この記事ではこれらの細かい部分については言及しません。

# 検証環境
- Node.js 12.19.0
- Sequelize 6.3.5
- Sequelize-CLI 6.2.0
- MySQL 5.7.25

# 方法

## まずは雛形のModelを作成する
今回もSequelize-CLIを使ってModelファイルとマイグレーションファイルを作成します。
ここではuserというテーブルにnameとageフィールドを持たせます。

    npx sequelize model:generate --name user --underscored --attributes name:string,age:integer

※ --underscoredを指定しているのはテーブル定義上は名称をスネークケース、コード上はキャメルメースにしたいだけで、本質的には今回の件と関係ありません。
※ 生成されるファイル内容については前回と同様なので省略します。

## Modelの設定を変更する
Sequelizeに楽観的ロックの動作を行わせるには、Modelに`version: true`を設定するだけです。

#### user.jsを編集する
```JavaScript
'use strict';
const {
  Model
} = require('sequelize');
module.exports = (sequelize, DataTypes) => {
  class user extends Model {
    /**
     * Helper method for defining associations.
     * This method is not a part of Sequelize lifecycle.
     * The `models/index` file will call this method automatically.
     */
    static associate(models) {
      // define association here
    }
  };
  user.init({
    name: DataTypes.STRING,
    age: DataTypes.INTEGER
  }, {
    sequelize,
    modelName: 'sample',
    underscored: true,
    version: true,
  });
  return user;
};
```

## マイグレーションファイルに`version`を追加する
テーブルに`version`列が必要なので、定義を追加します。
ちなみにここでは`version`にデフォルト値を設定していませんが、ORマッパーはINSERT時にも自動的に値を設定するので動作します。

#### xxxxxxxxxxx-create-user.jsを編集する
```JavaScript
'use strict';
module.exports = {
  up: async (queryInterface, Sequelize) => {
    await queryInterface.createTable('users', {
      id: {
        allowNull: false,
        autoIncrement: true,
        primaryKey: true,
        type: Sequelize.INTEGER
      },
      name: {
        type: Sequelize.STRING
      },
      age: {
        type: Sequelize.INTEGER
      },
      created_at: {
        allowNull: false,
        type: Sequelize.DATE
      },
      updated_at: {
        allowNull: false,
        type: Sequelize.DATE
      },
      version: {
        allowNull: false,
        type: Sequelize.INTEGER
      }
    });
  },
  down: async (queryInterface, Sequelize) => {
    await queryInterface.dropTable('users');
  }
};
```

# 動作を確認する

うまく動作するのかを検証します。
**※ 結論からいうとうまく動作するので、Sequelizeでの楽観的ロックの設定だけ知りたい方は、「以上、終了」という感じです。**

マイグレーションを実行してテーブルを準備します。

    npx sequelize db:migrate

## 正常系

#### まずはインサートしてみる

```JavaScript
await db.user.create({
    name: "sample",
    age: 10
});
```

下記のデータが登録されました。

|id|name|age|created_at|updated_at|version|
|:--|:--|:--|:--|:--|:--|
|1|sample|10|実行日時|実行日時|0|

#### 次はこれをアップデートしてみます。

```JavaScript
const user = await db.user.findOne({
    where: {
        id: 1
    }
});
user.age += 1;
await user.save();
```

下記のようにデータが更新されました。
`version`が自動的にインクリメントされており、どうやらうまく動作しているようです。

|id|name|age|created_at|updated_at|version|
|:--|:--|:--|:--|:--|:--|
|1|sample|11|以前の日時|今回の実行日時|1|

## 異常系
それでは上記の手順に続いて、本当に楽観ロックが機能しているのかを確認します。

同時にアクセスがあり、トランザクションを開始してデータを取得して更新するケースを想定します。

この時二つのトランザクションA、トランザクションBは同じ状態のデータを取得することになります。
その後、トランザクションAのほうは`age+1`を行いコミットします。
トランザクションBのほうは`age+10`を行ってコミットするという想定でコードを書いてみます。

※ トランザクション分離レベルは`REPEATABLE READ`で行っています。

```JavaScript
// 同時にトランザクションを開始する。
let transactionA = await db.sequelize.transaction();
let transactionB = await db.sequelize.transaction();

try {
    // まずトランザクションAでデータを取得
    const userA = await db.user.findOne({
        where: {
            id: 1
        }
    }, { transaction: transactionA });
    console.log(`A age: ${userA.age} version: ${userA.version}`);

    // 次にトランザクションBでデータを取得
    const userB = await db.user.findOne({
        where: {
            id: 1
        }
    }, { transaction: transactionB });
    console.log(`B age: ${userB.age} version: ${userB.version}`);

    // トランザクションAがage+1してコミット
    userA.age += 1;
    console.log("A update");
    await userA.save({ transaction: transactionA });
    console.log("A commit");
    transactionA.commit();
    transactionA = null;

    // トランザクションBがage+10してコミット
    userB.age += 10;
    console.log("B update");
    await userB.save({ transaction: transactionB });
    console.log("B commit");
    transactionB.commit();
    transactionB = null;
} catch (error) {
    console.log(error);
    if (transactionA) {
        console.log("rollback A");
        transactionA.rollback();
    }
    if (transactionB) {
        console.log("rollback B");
        transactionB.rollback();
    }
}
```

これを実行すると下記の順でログが出力されました。

```
A age: 11 version: 1
B age: 11 version: 1
A update
A commit
B update
OptimisticLockError [SequelizeOptimisticLockError]: Attempting to update a stale model instance: user
（省略）
rollback B
```

このログから分かる通り、AもB同じデータを参照しており、Aはコミットに成功しましたが、Bはupdate実行時に`OptimisticLockError`例外が発生し、ロールバックされたようです。

結果のデータベースは下記のようになっています。

|id|name|age|created_at|updated_at|version|
|:--|:--|:--|:--|:--|:--|
|1|sample|12|以前の日時|今回の実行日時|2|

Aが行った操作である`age+1`が反映されていて、`version`は1になっています。

# まとめ
ということで動作検証も行ったので記事が長くなりましたが、Sequelizeで楽観ロックを導入するのはとても簡単でした。

明日は[@kerimeka](https://qiita.com/kerimeka)さんです！