Mongooseのバージョニング

Mongooseは3からバージョニングという機能が入ったんだけどよくわかってなかったので調べたメモ。

詳しくはここに書いてある。
http://aaronheckmann.tumblr.com/post/48943525537/mongoose-v3-part-1-versioning

バージョニングを使うと何がいいかというと、配列でネストしたスキーマをもつ場合などに、更新や削除の処理が並列に走ると要素がずれるという問題が解決できるらしい。

次のような検証スクリプトを書いた。

var mongoose = require('mongoose')
  , Schema = mongoose.Schema
  , ObjectId = mongoose.Schema.ObjectId;

mongoose.connect('mongodb://localhost/version_key_test', function(err) {
  console.log(err);
});
mongoose.set('debug', true);
  
var commentSchema = new Schema({ 
    body: String
  , user: String
  , created: { type: Date, default: Date.now }
});
var postSchema = new Schema({ comments: [commentSchema] });
var Post = mongoose.model('Post', postSchema);

Post.remove(function() {
  var post = new Post();
  post.comments.push({ body: '1', user: '1'});
  post.comments.push({ body: '2', user: '2' });
  post.comments.push({ body: '3', user: '3' });

  var commentId = post.comments[1]._id;

  post.save(function(err, post) {
    // 最初の要素を削除
    Post.findById(post._id, function(err, post) {
      post.comments.shift();
      post.save(function(err) {
        if (err) console.log(err);
      });
    });

    // 二番目の要素を更新
    Post.findById(post._id, function(err, post) {
      setTimeout(function() {
        var comment = post.comments.id(commentId);
        comment.body = 'new 2';
        post.save(function(err) {
          if (err) console.log(err);
        });
      }, 100);
    });
  });
});

これはコメントの削除と更新が同時に行われたケースを再現してる。これを実行すると、Mongoose v2.xでは意図せぬ挙動になる。具体的には次のような結果になる。

> db.posts.find().pretty()
{
  "_id" : ObjectId("5215ddc11c18b90000000002"),
  "comments" : [
    {
      "body" : "2",
      "user" : "2",
      "_id" : ObjectId("5215ddc11c18b90000000004"),
      "created" : ISODate("2013-08-22T09:45:37.903Z")
    },
    {
      "body" : "new 2",
      "user" : "3",
      "_id" : ObjectId("5215ddc11c18b90000000005"),
      "created" : ISODate("2013-08-22T09:45:37.903Z")
    }
  ]
}

user 3 のcomments.body が new 2 になってるのがわかる。なぜかというと、更新のほうで次のようなクエリが走るから。

// 更新のクエリ
posts.update(
  { _id: 5215ddc11c18b90000000002 },
  { '$set': { 'comments.1.body': 'new 2' } }
})

comments.1.body は findById で post を取得した時点では user 2 のデータを指してるんだけど、post を取得してから更新の処理が走る間に削除の処理が走るために user 3 のデータが書き換わってしまうというわけ。
これが Mongoose v3.x のバージョニングを使えば解決できる。v3.x では __v というフィールドを Mongoose が勝手に作って、配列の要素を削除や追加した場合に __v の値をインクリメントする。そして更新の際に __v の値を find の条件に入れるので、上記のスクリプトを実行すると、更新の処理がエラーになる。クエリはこんな感じで走る。

// 削除のクエリ
posts.update(
  {
    _id: ObjectId("5215ddde10f4fd0000000002"),
    __v: 0
  },
  {
    '$inc': { __v: 1 },
    '$set': { comments: ... }
  }
)

// 更新のクエリ
posts.update(
  {
    _id: ObjectId("5215ddde10f4fd0000000002"),
    __v: 0
  },
  { '$set': { 'comments.1.body': 'new 2' } }
)

更新のクエリはさっきと同じように comments.1.body を変更しようとしてるんだけど、検索部分に __v: 0 というのが追加されている。そしてその前の削除のクエリで '$inc': { __v: 1 } という処理を実行しているため、更新のクエリは条件にマッチせず、更新自体が行われない。

更新のほうのsaveメソッドでは次のようなエラーが発生する。

{ message: 'No matching document found.', name: 'VersionError' }

__v とかいうフィールドを持たせるのかなりダサイけどまあ現実的な解決方法なんだろうな、たぶん。

しかしトランザクションがないとこういう処理が大変なんだんなあ・・