Mercurial-2.6から追加されたunionスキームについて調べてみた。

Mercurial-2.6から追加されたunionスキームについて調べてみた。

Mercurial-2.6からリポジトリ指定時にunionというスキームが指定できるようになりました。 unionを利用すると、次の形式で指定した2つのローカルリポジトリの履歴をミックスして閲覧できるようになります。

union:repo1+repo2 # repo1とrepo2の履歴を合算して閲覧する

この機能を利用すれば、crew+main*1を手元で模倣できます。

$ hg clone http://hg.intevation.org/mercurial/ main
$ hg clone http://hg.intevation.org/mercurial/crew/ crew
$ MAIN_REV=$(hg -R main log -b default -l 1 --template "{node}")
$ CREW_REV=$(hg -R crew log -b default -l 1 --template "{node}")
$ hg -R union:crew+main heads
$ hg -R union:crew+main log -r $CREW_REV -r $MAIN_REV
$ hg -R union:crew+main log -r "::$CREW_REV-::$MAIN_REV"
$ hg -R union:crew+main log -r "ancestor($CREW_REV,$MAIN_REV)"
$ hg -R union:crew+main diff -r $CREW_REV -r $MAIN_REV

mercurial/unionrepo.pyのコミットコメントも使い方が書いてあります。参照してみてください。

なんでこんなものが追加されたの?

コミットコメントにある通り、RhodecodeやBitbucketなどのホスティングサービスで利用するような機能がCoreにあると便利だよね!ってことでRhodeCodeで利用されていたコードをMercurial本体に導入したみたいです。該当のコードはこちら。

Rhodecodeではリポジトリ同士の比較(rhodecode/controller/compare.py)やプルリクエスト(rhodecode/model/pull_request.py)などで利用されているようです。

*1:コミッターリポジトリ(crew)とメインリポジトリ(main)を自動的にミックスしているリポジトリ

Mercurial-2.6の並列になったupdateの注意点

いくつか注意点があることを id:flying-foozy さんに指摘してもらったので紹介します。

並列updateで10万規模のファイルを一度に更新するとlinux kernelでsoft lockupと誤認される

10万規模のファイルを更新するとlinux kernelでsoft lockupと誤認されるようです。

バグとして登録されているのですが、linux kernel側の問題ということで、mercurial側で対応する気はないようです。

soft lockupとはタスクやカーネルスレッドがCPUを一定時間専有している状態(一定時間割り込みが禁止されている状態)を指すらしく、 通常はカーネルのバグかハードウェアのバグで発生するみたいです。(遭遇したこと無い、、)

そもそも10万ファイルあるような巨大なリポジトリを利用するケースは少ないと思いますが、*1 この問題に遭遇した場合は、次で紹介するworker.numcpusで調整すると良いかもしれません。

並列実行のworker数はhgrcのworker.numcpusで設定可能

並列実行のworker数はhgrcのworker.numcpusで設定可能です。

[worker]
numcpus = 1 # 並列実行しない

未設定の場合、worker数はCPU数に応じて4から32の間になります。

POSIX環境(≒Windows)では並列updateが利用できない

POSIX環境ではworkerの起動コスト1e30という非常に大きな値にする事によって実質並列updateが行われないように調整されています。

気になった点

よく見ると、この2つのif文の条件は両方ともif os.name == 'posix'で良い気がします。

*1:mozilla-centralでさえ7万8千ファイル

Mercurial-2.6-rcの並列になったupdateをmozilla-centralで試す

rhodecodeのリポジトリよりずっと巨大なmozilla-centralでMercurial-2.6-rcの並列になったupdateを試してみた。

mozilla-centralの大きさはこんな感じ。13万リビジョン、7万8千ファイルある。

% hg tip
リビジョン:   129561:aa620f3fc2f7
タグ:         tip
ユーザ:       Matthew Noorenberghe <mozilla@noorenberghe.ca>
日付:         Mon Apr 22 17:19:44 2013 -0700
要約:         Bug 841967 - Use performance.now() for the popup notification security delay since it's monotonically increasing. r=dolske

% hg manifest -r tip | wc -l
   78109

ちなみに、リポジトリの取得には次のURLの方法を利用した。

速度を測ってみた

% hg up FIREFOX_AURORA_21_BASE; time hg up FIREFOX_AURORA_22_BASE
9911 files updated, 0 files merged, 8042 files removed, 0 files unresolved
14638 files updated, 0 files merged, 3315 files removed, 0 files unresolved
hg up FIREFOX_AURORA_22_BASE  16.35s user 7.11s system 74% cpu 31.578 total

% hg up FIREFOX_AURORA_21_BASE; time hg up FIREFOX_AURORA_22_BASE
9911 files updated, 0 files merged, 8042 files removed, 0 files unresolved
14638 files updated, 0 files merged, 3315 files removed, 0 files unresolved
hg up FIREFOX_AURORA_22_BASE  15.97s user 6.35s system 74% cpu 29.815 total
% hg up FIREFOX_AURORA_21_BASE; time hg up FIREFOX_AURORA_22_BASE
ファイルの更新数 9911、 マージ数 0、 削除数 8042、 衝突未解消数 0
ファイルの更新数 14638、 マージ数 0、 削除数 3315、 衝突未解消数 0
hg up FIREFOX_AURORA_22_BASE  14.36s user 7.24s system 139% cpu 15.429 total

% hg up FIREFOX_AURORA_21_BASE; time hg up FIREFOX_AURORA_22_BASE
ファイルの更新数 9911、 マージ数 0、 削除数 8042、 衝突未解消数 0
ファイルの更新数 14638、 マージ数 0、 削除数 3315、 衝突未解消数 0
hg up FIREFOX_AURORA_22_BASE  13.67s user 6.74s system 149% cpu 13.652 total

平均と差分

;; mercurial-2.5.4
(/ (+ 31.578 29.815) 2)
; => 30.6965

;; mercurial-2.6-rc
(/ (+ 15.429 13.652) 2)
; => 14.5405

(- (/ (+ 31.578 29.815) 2) (/ (+ 15.429 13.652) 2))
; => 16.156

倍以上早くなっている。巨大なリポジトリを扱う場合は効果が大きい。

(追記)注意点

Mercurial-2.6-rcの並列になったupdateを試す

多数のパフォーマンスチューニングを含むMercurial-2.6のリリース候補版がリリースされた。今回のリリースからhg updateの処理が並列化されるようになったので試してみた。

インストール

バージョンを指定してインストール。

% pip install -U mercurial==2.6-rc

hg updateの速度を見てみる

2.6からhg updateが並列で動くらしいのでrhodecodeのリポジトリで試してみる。

% hg clone https://bitbucket.org/marcinkuzminski/rhodecode
$ cd rhodecode
% hg up null > /dev/null; time (hg up default > /dev/null && hg up beta > /dev/null)
( hg up default > /dev/null && hg up beta > /dev/null; )  1.56s user 0.62s system 72% cpu 3.018 total
% hg up null > /dev/null; time (hg up default > /dev/null && hg up beta > /dev/null)
( hg up default > /dev/null && hg up beta > /dev/null; )  2.15s user 0.63s system 95% cpu 2.897 total
% hg up null > /dev/null; time (hg up default > /dev/null && hg up beta > /dev/null)
( hg up default > /dev/null && hg up beta > /dev/null; )  2.41s user 0.65s system 96% cpu 3.181 total
% hg up null > /dev/null; time (hg up default > /dev/null && hg up beta > /dev/null)
( hg up default > /dev/null && hg up beta > /dev/null; )  2.10s user 0.60s system 96% cpu 2.792 total
% hg up null > /dev/null; time (hg up default > /dev/null && hg up beta > /dev/null)
( hg up default > /dev/null && hg up beta > /dev/null; )  2.33s user 0.78s system 116% cpu 2.684 total
% hg up null > /dev/null; time (hg up default > /dev/null && hg up beta > /dev/null)
( hg up default > /dev/null && hg up beta > /dev/null; )  2.06s user 0.66s system 134% cpu 2.024 total
% hg up null > /dev/null; time (hg up default > /dev/null && hg up beta > /dev/null)
( hg up default > /dev/null && hg up beta > /dev/null; )  2.00s user 0.64s system 137% cpu 1.913 total
% hg up null > /dev/null; time (hg up default > /dev/null && hg up beta > /dev/null)
( hg up default > /dev/null && hg up beta > /dev/null; )  2.65s user 0.79s system 118% cpu 2.908 total

mercurial-2.6-rcのCPUの部分が100%を超えている。

  • 平均と差分
;; mercurial-2.5.4
(/ (+ 3.018 2.897 3.181 2.792) 4)
; => 2.972

;; mercurial-2.6-rc
(/ (+ 2.684 2.024 1.913 2.908) 4)
; => 2.38225

(- (/ (+ 3.018 2.897 3.181 2.792) 4) (/ (+ 2.684 2.024 1.913 2.908) 4))
; => 0.58975

この例だと、0.5秒ほど早くなっている。

(追記)注意点

DataMapperでOptimisticLock(楽観ロック)を利用する

dm-core-1.2.0の話です。

DataMapperでOptimisticLock(楽観ロック)を実現するプラグインはいくつか検索でヒットするのですが、古すぎて利用できなかったり、UPDATE文のWHERE句に条件が入るのではなく、プログラム側で判断しているためアトミック性がなかったりします。 ということでモンキーパッチです。*1

モンキーパッチ

module DataMapper
  class StaleObjectError < PersistenceError
    def initialize(query = nil)
      @query = query
    end

    def message
      if @query
        "Attempted to update a stale object: #{@query.model.name}"
      else
        "Attempted to update a stale object"
      end
    end
  end

  module Adapters

    # dm-do-adapter-1.2.0/lib/dm-do-adapter/adapter.rb
    DataObjectsAdapter.class_eval do
      DEFAULT_LOCKING_COLUMN = 'lock_version'

      alias_method :original_update, :update
      def update(attributes, collection)
        query = collection.query
        locking_property = query.model.properties(name)[locking_column.to_sym]

        if locking_property.blank? or collection.length != 1
          return original_update(attributes, collection)
        end

        properties  = []
        bind_values = []

        # make the order of the properties consistent
        query.model.properties(name).each do |property|
          next unless attributes.key?(property)
          properties  << property
          bind_values << attributes[property]
        end

        # Add a condition to confirm whether a locking column is the same version.
        query = query.dup.update(locking_column.to_sym => collection.first.__send__(locking_column.to_sym))
        properties  << locking_property
        next_locking_column_value = collection.first.__send__(locking_column.to_sym) + 1
        bind_values << next_locking_column_value

        statement, conditions_bind_values = update_statement(properties, query)

        bind_values.concat(conditions_bind_values)

        affected_rows = with_connection do |connection|
          connection.create_command(statement).execute_non_query(*bind_values)
        end.affected_rows

        unless affected_rows == 1
          raise DataMapper::StaleObjectError.new(query)
        end

        collection.first.__send__("#{locking_column}=".to_sym, next_locking_column_value)

        affected_rows
      end

      def locking_column=(value)
        @original_locking_column = @locking_column if defined?(@locking_column)
        @locking_column          = value.to_s
      end

      def locking_column
        reset_locking_column unless defined?(@locking_column)
        @locking_column
      end

      def reset_locking_column
        self.locking_column = DEFAULT_LOCKING_COLUMN
      end

    end
  end
end

使い方

モデルにlock_versionというカラムを追加します。

class User
  include DataMapper::Resource

  property :id, Serial
  property :name, String
  property :lock_version, Integer, default: 0
end

モデルのlock_versionとDBのlock_versionの値と異なるとDataMapper::StaleObjectErrorが発生します。

a = User.create(name: 'troter')
# INSERT INTO `users` (`name`, `lock_version`) VALUES ('troter', 0)
# => #<User @id=1 @name="troter" @lock_version=0>
a.name = 'TROTER'
a.save
# UPDATE `users` SET `name` = 'TROTER', `lock_version` = 1 WHERE (`id` = 1 AND `lock_version` = 0)
a
# => #<User @id=1 @name="TROTER" @lock_version=1>
a.name = 'troter'
a.lock_version = 0
a.save
# UPDATE `users` SET `name` = 'troter', `lock_version` = 0, `lock_version` = 1 WHERE (`id` = 1 AND `lock_version` = 0)
# DataMapper::StaleObjectError: DataMapper::StaleObjectError
#         from /home/troter/sandbox/datamapper-sandbox/project_name/config/initializers/data_mapper.rb:143:in `update'        from /home/troter/.rbenv/versions/1.9.3-p392/lib/ruby/gems/1.9.1/gems/dm-core-1.2.0/lib/dm-core/repository.rb:180:in `update'
#         from /home/troter/.rbenv/versions/1.9.3-p392/lib/ruby/gems/1.9.1/gems/dm-core-1.2.0/lib/dm-core/resource/persistence_state/dirty.rb:54:in `update_resource'
#         from /home/troter/.rbenv/versions/1.9.3-p392/lib/ruby/gems/1.9.1/gems/dm-core-1.2.0/lib/dm-core/resource/persistence_state/dirty.rb:22:in `commit'
#         from /home/troter/.rbenv/versions/1.9.3-p392/lib/ruby/gems/1.9.1/gems/dm-core-1.2.0/lib/dm-core/resource.rb:956:in `_persist'
#         from /home/troter/.rbenv/versions/1.9.3-p392/lib/ruby/gems/1.9.1/gems/dm-core-1.2.0/lib/dm-core/resource.rb:987:in `block in update_with_hooks'
#         from /home/troter/.rbenv/versions/1.9.3-p392/lib/ruby/gems/1.9.1/gems/dm-core-1.2.0/lib/dm-core/resource.rb:984:in `catch'
#         from /home/troter/.rbenv/versions/1.9.3-p392/lib/ruby/gems/1.9.1/gems/dm-core-1.2.0/lib/dm-core/resource.rb:984:in `update_with_hooks'
#         ...省略

これで、安心して楽観ロックが利用できます。

*1:誰かgemにして。。

DataMapperのdm-migrationsのマイグレーションファイルの通し番号は重複していても問題ない

dm-migrations-1.2.0の話です。

dm-railsで次のようにdm-migrationsのマイグレーションファイルを生成すると、001から始まる通し番号のマイグレーションファイルが作成されます。

$ bundle exec rails generate migration create_users
      invoke  data_mapper
      create    db/migrate/001_create_users.rb

複数人で開発を行なっていると、データベースのマイグレーションスクリプトの番号が衝突しがちです。これは困ったなーとはじめのうちは通し番号の調整を行なっていたのですが、無駄な作業だという事がわかりました。

dm-migrationsのマイグレーション情報の管理方法

マイグレーションの適用状況を管理するテーブルmigration_infoのテーブル定義は次の通りです。

CREATE TABLE `migration_info` (
  `migration_name` varchar(255) COLLATE utf8_unicode_ci DEFAULT NULL,
  UNIQUE KEY `migration_name` (`migration_name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci

migration_name、名前の方しか管理していません。実は通し番号はおまけで、名前が重要なのでした。

通し番号が重複した場合どうなるのか。

実際に重複している場合の動作を確認してみます。次のようなファイルを用意した場合のマイグレーションの適用状況を考えます。

  • db/migrate/001_create_users.rb
# -*- coding: utf-8 -*-

migration 1, :create_users do

  up do
    create_table :users do
      column :id, Integer, serial: true
      column :name, String
      column :created_at, DateTime
      column :updated_at, DateTime
    end
  end

  down do
    drop_table :users
  end

end
  • db/migrate/002_create_categories.rb
# -*- coding: utf-8 -*-

migration 2, :create_categories do

  up do
    create_table :categories do
      column :id, Integer, serial: true
      column :name, String, size: 20, not_null: true
      column :created_at, DateTime
      column :updated_at, DateTime
    end
  end

  down do
    drop_table :categories
  end

end
  • db/migrate/002_create_movies.rb
# -*- coding: utf-8 -*-

migration 2, :create_movies do

  up do
    create_table :movies do
      column :id, Integer, serial: true
      column :title, String, size: 255, not_null: true
      column :released_on, Date
      column :imdb_url, String, size: 255
      column :created_at, DateTime
      column :updated_at, DateTime
    end
  end

  down do
    drop_table :movies
  end

end
  • db/migrate/003_create_category_movies.rb
# -*- coding: utf-8 -*-

migration 3, :create_category_movies do

  up do
    create_table :category_movies do
      column :category_id, Integer, primary_key: true
      column :movie_id, Integer, primary_key: true
    end

    # auto_migrateではdm-constraintsの機能でFK制約が作成されるが
    # db-migrationsからその機能は利用できない。
    create_index :category_movies, :category_id
    create_index :category_movies, :movie_id
  end

  down do
    drop_table :category_movies
  end

end

bundle exec rake db:migrate

マイグレーションを実行してみましょう。

$ bundle exec rake db:migrate 
 == Performing Up Migration #1: create_users
   CREATE TABLE `users` (`id` SERIAL PRIMARY KEY, `name` VARCHAR(50), `created_at` DATETIME, `updated_at` DATETIME) ENGINE = InnoDB CHARACTER SET utf8 COLLATE utf8_general_ci COMMENT=''
   -> 0.1809s
 -> 0.1855s
 == Performing Up Migration #2: create_categories
   CREATE TABLE `categories` (`id` SERIAL PRIMARY KEY, `name` VARCHAR(20), `created_at` DATETIME, `updated_at` DATETIME) ENGINE = InnoDB CHARACTER SET utf8 COLLATE utf8_general_ci COMMENT=''
   -> 0.2658s
 -> 0.2682s
 == Performing Up Migration #2: create_movies
   CREATE TABLE `movies` (`id` SERIAL PRIMARY KEY, `title` VARCHAR(255), `released_on` DATE, `imdb_url` VARCHAR(255), `created_at` DATETIME, `updated_at` DATETIME) ENGINE = InnoDB CHARACTER SET utf8 COLLATE utf8_general_ci COMMENT=''
   -> 0.1742s
 -> 0.1772s
 == Performing Up Migration #3: create_category_movies
   CREATE TABLE `category_movies` (`category_id` INTEGER, `movie_id` INTEGER) ENGINE = InnoDB CHARACTER SET utf8 COLLATE utf8_general_ci COMMENT=''
   -> 0.1572s
   CREATE INDEX `index_category_movies_category_id` ON `category_movies` (`category_id`)
   -> 0.1088s
   CREATE INDEX `index_category_movies_movie_id` ON `category_movies` (`movie_id`)
   -> 0.1344s
 -> 0.4025s

全部適用されます。migration_infoは次のとおり。

> select * from migration_info;
+------------------------+
| migration_name         |
+------------------------+
| create_categories      |
| create_category_movies |
| create_movies          |
| create_users           |
+------------------------+
4 rows in set (0.00 sec)

bundle exec rake 'db:migrate:down[1]'

マイグレーションの状態を1に戻してみましょう

$ bundle exec rake 'db:migrate:down[1]'
 == Performing Down Migration #3: create_category_movies
   DROP TABLE `category_movies`
   -> 0.0310s
 -> 0.0312s
 == Performing Down Migration #2: create_movies
   DROP TABLE `movies`
   -> 0.0678s
 -> 0.0679s
 == Performing Down Migration #2: create_categories
   DROP TABLE `categories`
   -> 0.0429s
 -> 0.0430s

2が両方とも外されました。migration_infoは次のとおり。

> select * from migration_info;
+-------------------+
| migration_name    |
+-------------------+
| create_users      |
+-------------------+
1 rows in set (0.00 sec)

bundle exec rake 'db:migrate:up[2]'

2だけ適応してみましょう。

$ bundle exec rake 'db:migrate:up[2]'
 == Performing Up Migration #2: create_categories
   CREATE TABLE `categories` (`id` SERIAL PRIMARY KEY, `name` VARCHAR(20), `created_at` DATETIME, `updated_at` DATETIME) ENGINE = InnoDB CHARACTER SET utf8 COLLATE utf8_general_ci COMMENT=''
   -> 0.1353s
 -> 0.1442s
 == Performing Up Migration #2: create_movies
   CREATE TABLE `movies` (`id` SERIAL PRIMARY KEY, `title` VARCHAR(255), `released_on` DATE, `imdb_url` VARCHAR(255), `created_at` DATETIME, `updated_at` DATETIME) ENGINE = InnoDB CHARACTER SET utf8 COLLATE utf8_general_ci COMMENT=''
   -> 0.1231s
 -> 0.1254s

両方とも適用されました。migration_infoは次のとおり。

> select * from migration_info;
+-------------------+
| migration_name    |
+-------------------+
| create_categories |
| create_movies     |
| create_users      |
+-------------------+
3 rows in set (0.00 sec)

名前が重複していた場合どうなるか。

名前が重複したケースも試してみましょう。

$ cp db/migrate/003_create_category_movies.rb db/migrate/004_create_category_movies.rb
% bundle exec rake db:migrate
rake aborted!
Migration name conflict: 'create_category_movies'
/home/troter/.rvm/gems/ruby-1.9.3-p327/gems/dm-migrations-1.2.0/lib/dm-migrations/migration_runner.rb:43:in `migration'
db/migrate/004_create_category_movies.rb:3:in `<top (required)>'
/home/troter/.rvm/gems/ruby-1.9.3-p327/gems/activesupport-3.2.13/lib/active_support/dependencies.rb:245:in `load'
# ------------ 省略

コンフリクトという事でエラーになりました。

まとめ

DataMapperで比較的高速にidだけ取得する

dm-core-1.2.0の話です。

現在の検索条件でidだけ取得したい、、10000件くらい。って要望、よくあると思う。:fieldsで指定しても、モデルのインスタンスを作るため、sqlを直接実行した時にはかなわなかったりする。

なので、DataMapper::Collectionに次のようなメソッドを生やしている。

module DataMapper
  class Collection < LazyArray
    def select_statement(repository_name = :default)
      DataMapper.repository(repository_name).adapter.__send__(:select_statement, self.query)
    end

    def sql_select(repository_name = :default)
      return [] unless self.query.valid?
      sql, args = select_statement(repository_name)
      DataMapper.repository(repository_name).adapter.select(sql, *args)
    end
  end
end

モデルのクラスメソッドにしないのは、クエリチェインするとクエリが壊れる恐れがある ため。

SQLとパラメータの取得方法についてはmonoさんの記事を参考しにした。

使い方

:fields と組み合わせて使う。 :fields で指定した要素が1つの場合はStructを作らずに値がそのまま入るので便利。

User.all(:name.like => 'a%').all(fields: [:id]).sql_select
# => [1,2,3]

参考