読者です 読者をやめる 読者になる 読者になる

Ruby の文字列データの複製について

Ruby

Ruby で String オブジェクトを複製しても、文字列データは複製されません。

data = "a"*10*1024*1024
system "grep ^VmSize /proc/#$$/status"
t1 = Time.now
a = []
100.times do |i|
  a.push data.dup
end
t2 = Time.now
system "grep ^VmSize /proc/#$$/status"
printf "%.6f\n", t2-t1

実際に10MBの文字列を作って、100回dupする前後でプロセスのメモリサイズを比較してみても変わってません。

% ruby hoge.rb
VmSize:   56140 kB
VmSize:   56140 kB
0.000164

複製後に文字列を変更すると、そこで文字列データも複製されます。

data = "a"*10*1024*1024
system "grep ^VmSize /proc/#$$/status"
t1 = Time.now
a = []
100.times do |i|
  s = data.dup
  s[0] = 'a'
  a.push s
end
t2 = Time.now
system "grep ^VmSize /proc/#$$/status"
printf "%.6f\n", t2-t1

プロセスサイズが増えてるのが確認できます。10MBオブジェクトが100個なので1GBほど増えてます。

VmSize:   56140 kB
VmSize: 1080540 kB
0.337337

まあ、中身を変更したら複製されるのは当然なのですが、実は部分文字列を取り出すだけでも複製されてしまいます。

10MBの文字列のうち、先頭1MBを100回取り出します。

data = "a"*10*1024*1024
system "grep ^VmSize /proc/#$$/status"
t1 = Time.now
a = []
100.times do |i|
  a.push data[0, 1024*1024]
end
t2 = Time.now
system "grep ^VmSize /proc/#$$/status"
printf "%.6f\n", t2-t1

100MBほどサイズが増えてしまいました。

VmSize:   56104 kB
VmSize:  158904 kB
0.044682

なんでこんなことが起きるかというと、Ruby の String オブジェクトが内部で保持してる文字列データは NUL(\0) 終端されているからです。部分文字列の次のバイトを NUL にすると元の文字列が変わってしまうので、複製する必要があるのでした。

ちなみに、文字列末尾の取り出しでは複製されません。文字列末尾は NUL が次にあるからです。

data = "a"*10*1024*1024
system "grep ^VmSize /proc/#$$/status"
t1 = Time.now
a = []
100.times do |i|
  a.push data[-1024*1024, 1024*1024]
end
t2 = Time.now
system "grep ^VmSize /proc/#$$/status"
printf "%.6f\n", t2-t1
VmSize:   56136 kB
VmSize:   56136 kB
0.000061

イマイチだなーとツイートしたら、教えてもらえました。

SHARABLE_MIDDLE_SUBSTRING は Ruby 2.2 で導入されたようです。

ということで、SHARABLE_MIDDLE_SUBSTRING=1 を設定してコンパイルしてみた Ruby で試してみます。

% cflags=-DSHARABLE_MIDDLE_SUBSTRING=0 ./configure
% make install
VmSize:   56232 kB
VmSize:   56232 kB
0.000072

おおー、メモリサイズは増えないし時間も掛かってないです。すばらしい。

もうこれデフォルトでいいのでは? と思ったらまた教えてもらいました。

Rubyの拡張ライブラリ中では RSTRING_PTR() とか StringValuePtr() で String オブジェクトから文字列データの先頭ポインタを取り出すことができるのですが、それが NUL 終端されていると仮定している拡張ライブラリがあるかもしれなくて、それが動かなくなってしまうからってことですね。確かにありそうです。

ということで、行儀のいい拡張ライブラリだけ使ってることが確実なのであれば、SHARABLE_MIDDLE_SUBSTRING=1 を使うと、もしかするとメモリサイズが小さくなって速くなる…ことがあるかもしれません。

追記

Ruby 2.3.1 で SHARABLE_MIDDLE_SUBSTRING=1 でコンパイルした Ruby で gem install が動きませんでした。 調べてみたら、ホスト名からIPアドレスを求める部分に問題があるようで、

TCPSocket.new("rubygems.global.ssl.fastly.net", 80)

は動くんだけど、

TCPSocket.new("rubygems.global.ssl.fastly.netX".chop, 80)

は動きませんでした。(getaddrinfo: Name or service not known)

該当部分のソースはこんな感じです。

[raddrinfo.c]

        name = RSTRING_PTR(host);
        if (!name || *name == 0 || (name[0] == '<' && strcmp(name, "<any>") == 0)) {
            make_inetaddr(INADDR_ANY, hbuf, hbuflen);
            if (flags_ptr) *flags_ptr |= AI_NUMERICHOST;
        }
        else if (name[0] == '<' && strcmp(name, "<broadcast>") == 0) {
            make_inetaddr(INADDR_BROADCAST, hbuf, hbuflen);
            if (flags_ptr) *flags_ptr |= AI_NUMERICHOST;
        }
        else if (strlen(name) >= hbuflen) {
            rb_raise(rb_eArgError, "hostname too long (%"PRIuSIZE")",
                strlen(name));
        }
        else {
            strcpy(hbuf, name);
        }

RSTRING_PTR() で得られたポインタに対して strcmp(), strlen(), strcpy() とか NUL終端文字列を期待している関数を使っちゃってます。

まさか Ruby 本体に罠があるとは思いませんでした。今のところ人柱覚悟で使った方が良いかもしれません。

最近のCrystal

Crystal

昨年末に「Ruby脳にはCrystalつらい Advent Calendar 2015」というのを書きました。

qiita.com

当時の Crystal のバージョンは 0.10 だったのですが、その後バージョンがあがって改善されてたりするので、紹介します。

カレントディレクトリに .crystal を作らなくなった

tmtms.hatenablog.com

Crystal 0.16 で、CRYSTAL_CACHE_DIR 環境変数を設定しなくても、デフォルトで $HOME の下にキャッシュディレクトリを作るようになりました。

$HOME/.cache/crystal または $HOME/.crystal が使用されます。

否定条件でも型の絞り込みができるようになった

tmtms.hatenablog.com

もともと次のように書けば nil かそれ以外かで条件分岐できたのですが、

def hoge(str_or_nil)
  if str_or_nil
    p str_or_nil.size
  else
    puts "nil"
  end
end
hoge("abc") #=> 3
hoge(nil)   #=> nil

条件を反転させると、コンパイル時に型チェックでエラーになってしまってました。

def hoge(str_or_nil)
  if !str_or_nil
    puts "nil"
  else
    p str_or_nil.size  #=> undefined method 'size' for Nil
  end
end
hoge("abc")
hoge(nil)

Crystal 0.15 で後者の例もエラーにならなくなりました。地味に嬉しいです。

デフォルト値を持たない引数も名前付き引数として扱える

tmtms.hatenablog.com

Crystal は Ruby と異なり、メソッド定義時に名前付き引数を受けつけるための専用の構文はなく、デフォルト値つきの引数は名前付き引数として扱うことができていました。

def hoge(a, b=1, c=2)
  p [a, b, c]
end

hoge(123)                  #=> [123, 1, 2]
hoge(123, b: 456)          #=> [123, 456, 2]
hoge(123, c: 789)          #=> [123, 1, 789]
hoge(123, b: 456, c: 789)  #=> [123, 456, 789]
hoge(123, 456)             #=> [123, 456, 2]
hoge(123, 456, c: 789)     #=> [123, 456, 789]
hoge(123, 456, 789)        #=> [123, 456, 789]
hoge(a: 123)               #=> wrong number of arguments for 'hoge' (given 0, expected 1..3)

最後の行はデフォルト値を持たない引数を名前付き引数として使おうとしてコンパイル時エラーになります。

Crystal 0.16 から、デフォルト値がない引数でも名前付き引数として扱うことができるようになりました。

def hoge(a, b=1, c=2)
  p [a, b, c]
end

hoge(123)                  #=> [123, 1, 2]
hoge(123, b: 456)          #=> [123, 456, 2]
hoge(123, c: 789)          #=> [123, 1, 789]
hoge(123, b: 456, c: 789)  #=> [123, 456, 789]
hoge(123, 456)             #=> [123, 456, 2]
hoge(123, 456, c: 789)     #=> [123, 456, 789]
hoge(123, 456, 789)        #=> [123, 456, 789]
hoge(a: 123)               #=> [123, 1, 2]

Ruby の Hash 構文がタプルになってつらい!

Crystal 0.17 から、タプルリテラルが導入されました。Ruby の新し目の Hash リテラルと同じ表記です。

Crystal で Hash をリテラルで書こうとすると、シンボルをキーとする場合でも Ruby の古い表記で記述する必要があります。

hoge = {a: 123, b: "abc"}        #=> タプル
hoge = {:a => 123, :b => "abc"}  #=> Hash

ちょっとRuby脳にはつらいです。

ということで「Ruby脳にはCrystalつらい Advent Calendar 2015」26日目の記事でした!

MySQL Shell / X DevAPI / X Protocol

MySQL

MySQL 5.7.12 で突如登場した MySQL Shell とか X DevAPI とか X Protocol とかが面白そうだったので調べてみました。

Document Store とかも同じ文脈で語られてて、それぞれの用語が何を表してるのかややこしかったので、まずその辺から。

X Protocol

mysqlx プラグインを使用することで追加されるサーバー/クライアントプロトコル。ポート番号は 33060。

詳細→ https://dev.mysql.com/doc/internals/en/x-protocol.html

X DevAPI

各プログラミング言語用の新しいAPI。Document Store用のAPIも含む。今のところ、MySQL Shell JavaScript, MySQL Shell Python, Java, .Net, Node.js 用の API がある。X Protocol を使用。

詳細→ https://dev.mysql.com/doc/x-devapi-userguide/en/

MySQL Shell

X DevAPI が組み込まれた JavaScript / Python の対話型コマンドラインツール。

詳細→ https://dev.mysql.com/doc/refman/5.7/en/mysql-shell.html

Document Store

MySQLをドキュメントデータベースとして使う方法。 内部的には、JSON型の doc という名前の1カラムだけを持つテーブル(実際には _id という自動生成カラムもある)を作って、すべてのデータをJSONで突っ込むことでドキュメントデータベースとして使用している。

詳細→ https://dev.mysql.com/doc/refman/5.7/en/document-store.html

ということで MySQL Shell を使ってみます。

MySQL 5.7.12 のインストール

自分は Xubuntu を使っていて apt-get で MySQL をインストールすると 5.7.12 が入るので、お手軽だと思ったんですけど、これは mysqlx プラグインを含んでいないため使えませんでした。ここ http://dev.mysql.com/downloads/mysql/ から適当にインストールします。

個人的に MySQL の rpm や deb パッケージは信用してないので(my.cnf を /usr に置いたりする)、tarball からインストールします。

# cd /usr/local
# tar xf /tmp/mysql-5.7.12-linux-glibc2.5-x86_64.tar.gz
# ln -s mysql-5.7.12-linux-glibc2.5-x86_64 mysql
# cd mysql
# ./bin/mysqld --no-defaults --initialize
...
2016-05-10T02:24:00.559906Z 1 [Note] A temporary password is generated for root@localhost: krJ&LWZFv3:Q
# chown -R mysql:mysql .
# ./bin/mysqld --no-defaults --user=mysql --log-error=/tmp/mysql.err --daemonize >> /tmp/mysql.err 2>&1

mysqld --initialize の最後の行にパスワードが表示されるのでメモっときます。 いちいち --no-defaults をつけてるのは、Ubuntu の MySQL の設定を読まないようにするためです。 本当はちゃんと my.cnf を書けばいいんですけど、お試しなのでこのままで。

% mysql -uroot -p
Enter password: krJ&LWZFv3:Q
mysql>

この状態で何かクエリを実行しようとすると、

ERROR 1820 (HY000): You must reset your password using ALTER USER statement before executing this statement.

って怒られるので、ぶつぶつ言いながらパスワードを設定します。

mysql> set password = 'abcdefg';

テスト用のデータベースを作成し、mysqlx プラグインを有効にしておきます。

mysql> create database test;
mysql> install plugin mysqlx soname 'mysqlx.so';

MySQL Shell のインストール

MySQL Shell は http://dev.mysql.com/downloads/shell/ から持ってきます。

残念ながらバイナリ tarball は無いみたいなので、deb をダウンロードして入れました。

# dpkg -i mysql-shell_1.0.3-1ubuntu15.10_amd64.deb

MySQL Shell を使う

コマンド名は mysqlsh です。

% mysqlsh --sql --js --user=root test
Creating an X Session to root@localhost:33060
Enter password:
...
mysql-js>

URI形式でも接続できます。

% mysqlsh --sql --js --uri root@localhost/test

コマンドラインでパスワードを指定することもできます。

% mysqlsh --sql --js --user=root --password=abcdefg test
% mysqlsh --sql --js --uri root:abcdefg@localhost/test

--sql」がないと、後で出てくる「session.sql()」が何故か使えないので指定しています。

起動後は対話型 JavaScript として動きます。何故か日本語は入力できませんでした。

mysql-js> function fib(n) { if (n < 2) return n; else return fib(n-1) + fib(n-2); }
mysql-js> fib(10)
55

複数行でも記述できます。「...」に対して何も入力せずに改行すると複数行入力の終了とみなされます。

mysql-js> function fib(n) {
      ... if (n < 2)
      ...   return n;
      ... else
      ...   return fib(n-1) + fib(n-2);
      ... }
      ...
mysql-js> fib(10)
55

session, db という変数が接続とデータベースを表しているようです。

mysql-js> session
<NodeSession:root@localhost:33060/test>
mysql-js> db
<Schema:test>

普通にSQLとして使ってみます。

mysql-js> session.sql("CREATE TABLE hoge (id INT, str VARCHAR(32))")

mysql-js> db.tables
{
    "hoge": <Table:hoge>
}
mysql-js> db.hoge.insert(['id', 'str']).values(123, 'abc')
Query OK, 1 item affected (0.06 sec)

mysql-js> db.hoge.insert(['id', 'str']).values(456, 'def').values(789, 'ghi')
Query OK, 2 items affected (0.05 sec)

mysql-js> db.hoge.select(['id', 'str'])
+-----+-----+
| id  | str |
+-----+-----+
| 123 | abc |
| 456 | def |
| 789 | ghi |
+-----+-----+
3 rows in set (0.00 sec)

mysql-js> db.hoge.select(['id', 'str']).where('id=456')
+-----+-----+
| id  | str |
+-----+-----+
| 456 | def |
+-----+-----+
1 row in set (0.01 sec)

mysql-js> db.hoge.update().set('str', 'hoge').where('id = 456')
Query OK, 1 item affected (0.06 sec)

mysql-js> db.hoge.select(['id', 'str'])
+-----+------+
| id  | str  |
+-----+------+
| 123 | abc  |
| 456 | hoge |
| 789 | ghi  |
+-----+------+
3 rows in set (0.00 sec)

mysql-js> db.hoge.delete().where('id=456');
Query OK, 1 item affected (0.04 sec)

mysql-js> db.hoge.select(['id', 'str'])
+-----+-----+
| id  | str |
+-----+-----+
| 123 | abc |
| 789 | ghi |
+-----+-----+
2 rows in set (0.00 sec)

mysql-js> db.hoge.delete()
Query OK, 2 items affected (0.03 sec)

mysql-js> db.hoge.select(['id', 'str'])
Empty set (0.00 sec)

Document Store として使ってみます。 ドキュメントを格納するテーブルは Collection となり普通のテーブルとしては扱われません。

mysql-js> db.createCollection('fuga')
<Collection:fuga>
mysql-js> db.tables
{
    "hoge": <Table:hoge>
}
mysql-js> db.collections
{
    "fuga": <Collection:fuga>
}

実体は普通にテーブルです。_id は生成カラムなので、実質 JSON の doc カラムひとつだけのテーブルです。

mysql> show tables;
+----------------+
| Tables_in_test |
+----------------+
| fuga           |
| hoge           |
+----------------+
2 rows in set (0.00 sec)

mysql> show create table fuga\G
*************************** 1. row ***************************
       Table: fuga
Create Table: CREATE TABLE `fuga` (
  `doc` json DEFAULT NULL,
  `_id` varchar(32) GENERATED ALWAYS AS (json_unquote(json_extract(`doc`,'$._id'))) STORED NOT NULL,
  UNIQUE KEY `_id` (`_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
1 row in set (0.00 sec)

どうやらテーブル構造がポイントらしく、hoge テーブルに同じ型の doc と _id カラムを追加すると、Collection として扱われます。

mysql> ALTER TABLE hoge ADD doc json,
    -> add _id varchar(32) GENERATED ALWAYS AS (json_unquote(json_extract(`doc`,'$._id')))
    -> STORED NOT NULL UNIQUE;

テーブル構造はキャッシュされてるようなので、再接続してから確認します。

mysql-js> \connect root:abcdefg@localhost/test
Closing old connection...
Creating an X Session to root@localhost:33060/test
Default schema `test` accessible through db.

mysql-js> db.tables
{

}
mysql-js> db.collections
{
    "fuga": <Collection:fuga>, 
    "hoge": <Collection:hoge>
}
mysql-js> 

Collection として使ってみます。

mysql-js> db.fuga.add({abc: 123})
Query OK, 1 item affected (0.05 sec)

mysql-js> db.fuga.add([{name: 'tmtms'}, {text: 'AIUEO'}])
Query OK, 2 items affected (0.04 sec)

mysql-js> db.fuga.find()
[
    {
        "_id": "682d6ac3b616e6111432022c3a710274",
        "name": "tmtms"
    },
    {
        "_id": "a0478da8b616e6111432022c3a710274",
        "abc": 123
    },
    {
        "_id": "e82f6ac3b616e6111432022c3a710274",
        "text": "AIUEO"
    }
]
3 documents in set (0.00 sec)

mysql-js> db.fuga.find('abc=123')
[
    {
        "_id": "a0478da8b616e6111432022c3a710274",
        "abc": 123
    }
]
1 document in set (0.00 sec)

mysql-js> db.fuga.modify('abc=123').set('xyz', 789)
Query OK, 1 item affected (0.06 sec)

mysql-js> db.fuga.find('abc=123')
[
    {
        "_id": "a0478da8b616e6111432022c3a710274",
        "abc": 123,
        "xyz": 789
    }
]
1 document in set (0.00 sec)

mysql-js> db.fuga.modify('abc=123').unset('abc')
Query OK, 1 item affected (0.06 sec)

mysql-js> db.fuga.find('xyz=789')
[
    {
        "_id": "a0478da8b616e6111432022c3a710274",
        "xyz": 789
    }
]
1 document in set (0.00 sec)

mysql-js> db.fuga.remove('xyz=789')
Query OK, 1 item affected (0.06 sec)

mysql-js> db.fuga.find()
[
    {
        "_id": "682d6ac3b616e6111432022c3a710274",
        "name": "tmtms"
    },
    {
        "_id": "e82f6ac3b616e6111432022c3a710274",
        "text": "AIUEO"
    }
]
2 documents in set (0.01 sec)

おわりに

5.6 以降強化され続けている JSON 機能を使ってドキュメントデータベースのように使える API を用意し、そのためのプロトコルも作成したって感じでしょうか。

プロトコルはパイプライン処理ができるようになっていたりして、それ単体で見ても面白そうなので、そのうちちゃんと調べてみたいです。

MySQL ソケットピア証明書認証プラグイン

MySQL

全然知らなかったんですが、MySQLに「ソケットピア証明書認証プラグイン」というのがあるのを知りました。

http://dev.mysql.com/doc/refman/5.6/ja/socket-authentication-plugin.html

これを使うとOSのログインユーザーと同じ名前のMySQLユーザーであれば、パスワード無しでMySQLに接続することができます。ただしローカルホストでUNIXソケット経由での接続のみ。

試してみます。

% mysql -uroot
mysql> INSTALL PLUGIN auth_socket SONAME 'auth_socket.so';
mysql> CREATE USER tommy@localhost IDENTIFIED WITH auth_socket;
% whoami
tommy
% mysql
mysql> SELECT USER();
+-----------------+
| USER()          |
+-----------------+
| tommy@localhost |
+-----------------+

どうやら 5.5.10 からあるようです。今までずっとMySQLのユーザーとOSのユーザーは無関係だと思ってたのでちょっと驚きました。

Twitter からの文字化けメール

Twitter を名乗るメールアドレスからメールが届いたのですが、Subjectが文字化けしていました。

Subject: ã¨ã¿ãŸã¾ã•ã²ã‚さん、Twitterからの感謝のメッセージです。

いまのTwitterがあるのは、皆さんのおかげです。
10年前の今日、ひとつのツイートからすべてが始まりました。 
そのときから、Twitterは驚くべきことを皆さんと一緒に行いました。<BR> この瞬間にも何百万人の人々がつながり、それぞれの思いを自由に語り、<BR> かつては想像もできなかったインパクトを世界中に与えています。
Twitterの誕生日に、皆さんにあらためて感謝いたします。<BR> これまで支えてくださり、ありがとうございます!<BR> そして次の10年に向けてもよろしくお願いいたします。

Subject が文字化けしていることだけでなく、本文中に「<BR>」がそのまま入っていたり、本文中のリンク先が Twitter ではなく、http://cl.S7.exct.net/ というサイトへのリンクになっているので、かなり怪しい雰囲気なのですが、変なメール配信サイトを使ってるだけで多分本物だと個人的には思ってます。知りませんけど。

まあ本物かフィッシングメールかはどうでも良くて、本題は文字化けです。

「◯◯さん、Twitterからの感謝のメッセージです」という文面から、文字化け部分は名前だと推測できます。

メールの生ヘッダを見てみます。

Subject: =?UTF-8?B?w6PCgcKow6PCgcK/w6PCgcW4w6PCgcK+w6PCgeKAosOjwoHCssOj?=
 =?UTF-8?B?4oCawo3jgZXjgpPjgIFUd2l0dGVy44GL44KJ44Gu5oSf6Kyd44Gu44Oh44OD?=
 =?UTF-8?B?44K744O844K444Gn44GZ44CC?=

MIMEデコードして文字化けしている部分のバイト列を見てみます。

$ ruby -e 'p "w6PCgcKow6PCgcK/w6PCgcW4w6PCgcK+w6PCgeKAosOjwoHCssOj4oCawo3=".unpack("m")[0]'
"\xC3\xA3\xC2\x81\xC2\xA8\xC3\xA3\xC2\x81\xC2\xBF\xC3\xA3\xC2\x81\xC5\xB8\xC3\xA3\xC2\x81\xC2\xBE\xC3\xA3\xC2\x81\xE2\x80\xA2\xC3\xA3\xC2\x81\xC2\xB2\xC3\xA3\xE2\x80\x9A\xC2\x8D"

「C2 xx」や「C3 xx」が繰り返されているのは、たいてい Latin-1(ISO8859-1) を UTF-8 に変換した時のバイト列です。

元々何か別のエンコーディング文字列(おそらく UTF-8)を Latin-1 とみなして UTF-8 に変換してしまったものと思われます。

ということで、逆に変換してみます。

$ ruby -e 'p "w6PCgcKow6PCgcK/w6PCgcW4w6PCgcK+w6PCgeKAosOjwoHCssOj4oCawo3=".unpack("m")[0].encode("iso8859-1", "utf-8", undef: :replace)'
"\xE3\x81\xA8\xE3\x81\xBF\xE3\x81?\xE3\x81\xBE\xE3\x81?\xE3\x81\xB2\xE3?\x8D"

UTF-8 ぽいバイトの並びになりました。このバイト列を文字で表すと「とみ???ま???ひ???」となりました。 当初の推測通り私の名前「とみたまさひろ」が文字化けしていたようです。

「とみたまさひろ」をそのまま UTF-8 バイト列で表すと次のようになります。

$ ruby -e 'p "とみたまさひろ".b'
"\xE3\x81\xA8\xE3\x81\xBF\xE3\x81\x9F\xE3\x81\xBE\xE3\x81\x95\xE3\x81\xB2\xE3\x82\x8D"

9バイト目の\x9F, 15バイト目の\x95, 20バイト目の\x82 の文字の部分がうまく変換できなかったようですが、これの原因はわかりませんでした。

天下の Twitter さんなんだから、まともなメール配信システムを使って欲しいところです。

flock(LOCK_EX) で EBADF

Linux NFS Ruby

ちょっとハマったのでメモ。

ファイルを flock() で排他的にロックするために次のようにすると成功します。

% ruby -e 'File.open("hoge").flock(File::LOCK_EX); puts "OK"'
OK

が、NFS 上で同じことをやると失敗します。

% ruby -e 'File.open("hoge").flock(File::LOCK_EX); puts "OK"'
-e:1:in `flock': Bad file descriptor @ rb_file_flock - hoge (Errno::EBADF)
    from -e:1:in `<main>'

7年前に書いたんですが、Linux は NFS ファイルシステムに対して flock() すると fcntl(F_SETLK) を使います。

で、fcntl(F_SETLK)flock() と異なり、排他ロックをするにはファイルを書き込み可のモードでオープンしておかないといけません。

通常のローカルファイルシステムでも読み込み専用でオープンしたファイルに fcntl(F_SETLK) を使って排他ロックを行えば同じエラーが発生します。

% ruby -rfcntl -e 'File.open("hoge").fcntl(Fcntl::F_SETLK, [Fcntl::F_WRLCK, 0].pack("ss"))'
-e:1:in `fcntl': Bad file descriptor @ rb_fcntl - hoge (Errno::EBADF)
    from -e:1:in `<main>'

書き込みできるモードでオープンしておけばエラーにはなりません。

% ruby -rfcntl -e 'File.open("hoge", "r+").fcntl(Fcntl::F_SETLK, [Fcntl::F_WRLCK, 0].pack("ss")); puts "OK"'
OK

つまり、NFS 上でも書き込みできるモードでファイルをオープンしておけば flock() で排他制御できます。

% ruby -e 'File.open("hoge", "r+").flock(File::LOCK_EX); puts "OK"'
OK

ということで、flock(LOCK_EX) する場合は書き込みできるモードでファイルをオープンしておいた方が無難というお話でした。

Postfix 3.1 の新機能 / JSON形式キュー表示と配送流量制御

Postfix

Postfix 3.1 がリリースされました

個人的に気になった新機能は

  • JSON-format Postfix queue listing.
  • Destination-independent delivery rate delay

の2つです。

JSON形式キュー表示

今までは mailq や postqueue -p コマンドで次のような表示がされていました。

~% postqueue -p
-Queue ID-  --Size-- ----Arrival Time---- -Sender/Recipient-------
8A9AE6EF        275 Sun Mar  6 23:47:06  sender@example.com
                                                          (deferred transport)
                                         rcpt1@example.net
                                         rcpt2@example.net

94AC010A!       298 Sun Mar  6 23:46:33  sender@example.com
                                                          (deferred transport)
                                         rcpt@example.net

-- 0 Kbytes in 2 Requests.

人が読むには良いのですが、プログラムで処理するにはちょっと難しい形式でした。

これが JSON 形式で出力できるようになりました。postqueue -j コマンドを使用します。

~% postqueue -j
{"queue_name": "deferred", "queue_id": "8A9AE6EF", "arrival_time": 1457275626, "message_size": 275, "sender": "sender@example.com", "recipients": [{"address": "rcpt1@example.net", "delay_reason": "deferred transport"}, {"address": "rcpt2@example.net", "delay_reason": "deferred transport"}]}
{"queue_name": "hold", "queue_id": "94AC010A", "arrival_time": 1457275593, "message_size": 298, "sender": "sender@example.com", "recipients": [{"address": "rcpt@example.net", "delay_reason": "deferred transport"}]}

キュー毎に JSON 形式でリストされます。時刻はUNIX時刻(1970-01-01 00:00:00 UTC からの経過秒数)で表され、受信者は配列形式で表されます。プログラムで処理するのも簡単そうです。

配送流量制御

今まで Postfix は配送時の流量を制御することはできませんでした。相手が受け付けるだけメールを送信していました。

3.1 では明にメッセージ配送を遅延させることができるようになりました。

たとえば default_transport_rate_delay = 3s を設定すれば、キューに入ってから配送を開始するまで3秒間待つようになります。

smtp_transport_rate_delay = 3s とすれば smtp 配送だけに効果があります。

transport_maps と master.cf の設定をうまく使えば特定のドメイン宛だけ遅延させることもできると思います。

次は default_transport_rate_delay = 3s とした状態で、メールをほぼ同時に5通送った時の配送ログです。 約3秒間隔で配送されていることがわかります。

Mar  7 00:31:03 x220 postfix/smtp[29564]: 10D0610A: to=<tmtms@example.com>, relay=mx.example.com[49.212.128.207]:25, delay=3.8, delays=0.08/3.4/0.14/0.17, dsn=2.0.0, status=sent (250 2.0.0 Ok: queued as AA13B9FCBF)
Mar  7 00:31:03 x220 postfix/qmgr[29553]: 10D0610A: removed
Mar  7 00:31:07 x220 postfix/smtp[29564]: 6AD316EF: to=<tmtms@example.com>, relay=mx.example.com[49.212.128.207]:25, delay=6.7, delays=0.09/6.3/0.14/0.19, dsn=2.0.0, status=sent (250 2.0.0 Ok: queued as 046759FCBF)
Mar  7 00:31:07 x220 postfix/qmgr[29553]: 6AD316EF: removed
Mar  7 00:31:10 x220 postfix/smtp[29598]: BD2BC1A0E: to=<tmtms@example.com>, relay=mx.example.com[49.212.128.207]:25, delay=9.7, delays=0.12/9.3/0.14/0.17, dsn=2.0.0, status=sent (250 2.0.0 Ok: queued as 522C19FCBF)
Mar  7 00:31:10 x220 postfix/qmgr[29553]: BD2BC1A0E: removed
Mar  7 00:31:13 x220 postfix/smtp[29564]: 164231AA5: to=<tmtms@example.com>, relay=mx.example.com[49.212.128.207]:25, delay=13, delays=0.12/12/0.14/0.17, dsn=2.0.0, status=sent (250 2.0.0 Ok: queued as 9E3459FCBF)
Mar  7 00:31:13 x220 postfix/qmgr[29553]: 164231AA5: removed
Mar  7 00:31:17 x220 postfix/smtp[29598]: 642CC1CE2: to=<tmtms@example.com>, relay=mx.example.com[49.212.128.207]:25, delay=16, delays=0.12/15/0.14/0.18, dsn=2.0.0, status=sent (250 2.0.0 Ok: queued as EA5DD9FCBF)
Mar  7 00:31:17 x220 postfix/qmgr[29553]: 642CC1CE2: removed