Why CouchDB?

数年前にCouchDBのデータフォーマットについて解説しているページを読みました。CouchDBの内部ではデータをB+treeで保持しており、且つデータはappend onlyでMVCCである、という部分が面白くて、その時からCouchDBに興味がありました。しかしコードがErlangで書かれており、当時の私にはCouchDBのコードを読むことができませんでした。

1年ほど前からErlangのコードにちょっとずつ触れるようになり、CouchDBのコードも時間をかければ読めるような気がするので、気が向いた時にちょっとずつ読んでいこうと思います。

本当はある程度読んでまとめてからアウトプットしようと思っていたのですが、正直言ってかなり苦戦していて、まとめられる気がしなくなったので、読んだところからダラダラと書いていくことにしました。ですので、続かないかも。

Source

githubのapache/couchdbのmasterのコードを読んでいきます。

https://github.com/apache/couchdb

Data File

実際にCouchDBを動かしてみると、CouchDBのデータファイルは、データベースに対して1:1で作成されているようです。–prefix等を指定せずにmake && make installすると、データファイルは/usr/local/var/lib/couchdb/(database).couchに作成されます。1ファイルしか生成されないことに不安があるものの、コードを読んでいくうちにファイルの分割とかあると思うので、気にしないことにします。

データファイルのオープンは以下で行われています。

couch_db_updater.erl:

 1init({MainPid, DbName, Filepath, Fd, Options}) ->
 2    process_flag(trap_exit, true),
 3    case lists:member(create, Options) of
 4    true ->
 5        % create a new header and writes it to the file
 6        Header =  #db_header{},
 7        ok = couch_file:write_header(Fd, Header),
 8        % delete any old compaction files that might be hanging around
 9        RootDir = couch_config:get("couchdb", "database_dir", "."),
10        couch_file:delete(RootDir, Filepath ++ ".compact");                                                    
11    false ->
12       case couch_file:read_header(Fd) of
13        {ok, Header} ->                                                                                             
14            ok;
15        no_valid_header ->
16            % create a new header and writes it to the file
17            Header =  #db_header{},
18            ok = couch_file:write_header(Fd, Header),
19            % delete any old compaction files that might be hanging around
20            file:delete(Filepath ++ ".compact")
21        end
22    end,
23    ReaderFd = open_reader_fd(Filepath, Options),                                                                   
24    Db = init_db(DbName, Filepath, Fd, ReaderFd, Header, Options),
25    Db2 = refresh_validate_doc_funs(Db),      
26    {ok, Db2#db{main_pid = MainPid}}.

Optionsにcreateがある場合は空のヘッダを作成してファイルに書き込んでいます。そしてコンパクション時にデータを書き出すファイル(.compact)を削除しています。

Optionsにcreateがない場合はcouch_file:read_header/1で一旦ヘッダを読み込みますが、正常に読み込めることを確認したらopen_reader_fd/2で再度データファイルを読み直し、init_db/6でDBを扱う為の準備をします。

couch_file.erl:

1read_header(Fd) ->
2    case gen_server:call(Fd, find_header, infinity) of
3    {ok, Bin} ->
4        {ok, binary_to_term(Bin)};
5    Else ->
6        Else
7    end.  

couch_file:read_headerを読み進めてみます。と言ってもgen_server:call/3でヘッダをバイナリで受け取り、binary_to_term/1でTermに変換して返しているだけです。

couch_file.erl:

1handle_call(find_header, _From, #file{fd = Fd, eof = Pos} = File) ->
2    {reply, find_header(Fd, Pos div ?SIZE_BLOCK), File}.  

このリクエストを受け取っているのは、先ほどのread_header/1と同じくcouch_fileモジュールです。Fileは、同モジュールのinit/1で生成されており、PosにはEOFのポインタが設定されています。find_header/2load_header/2を呼び出します。

couch_file.erl:

 1load_header(Fd, Block) ->
 2    {ok, <<1, HeaderLen:32/integer, RestBlock/binary>>} =
 3        file:pread(Fd, Block * ?SIZE_BLOCK, ?SIZE_BLOCK),
 4    TotalBytes = calculate_total_read_len(5, HeaderLen),
 5    case TotalBytes > byte_size(RestBlock) of
 6    false ->
 7        <<RawBin:TotalBytes/binary, _/binary>> = RestBlock;
 8    true ->
 9        {ok, Missing} = file:pread(
10            Fd, (Block * ?SIZE_BLOCK) + 5 + byte_size(RestBlock),
11            TotalBytes - byte_size(RestBlock)),
12        RawBin = <<RestBlock/binary, Missing/binary>>
13    end,
14    <<Md5Sig:16/binary, HeaderBin/binary>> =
15        iolist_to_binary(remove_block_prefixes(5, RawBin)),
16    Md5Sig = couch_util:md5(HeaderBin),
17    {ok, HeaderBin}.

load_header/2では、データファイル内の最後のブロックの先頭から読み始めています。実際にデータファイルの最後のブロックを見ると以下のようになっています。

0002000: 0100 0000 5ab1 66f1 2aec 7d5a 34e4 5969  ....Z.f.*.}Z4.Yi
0002010: 5a1f 8b30 4a83 680b 6400 0964 625f 6865  Z..0J.h.d..db_he
0002020: 6164 6572 6106 6102 6100 6803 6200 0010  adera.a.a.h.b...
0002030: cd68 0361 0161 0061 7161 9a68 0362 0000  .h.a.a.aqa.h.b..
0002040: 1167 6101 616b 6400 036e 696c 6100 6400  .ga.akd..nila.d.
0002050: 036e 696c 6400 036e 696c 6200 0003 e8    .nild..nilb....

load_header/2の最初の行で、ヘッダ部を読み込んでいる。先頭1バイトは「1」固定、次の4バイトはヘッダのサイズ(90byte)、その次の16バイトはMD5の値、残りの部分はヘッダを表現するErlang termsとなります。この最後の部分がread_header/1の中のbinary_to_term/1によって変換され、以下のrecordになります。

couch_db.hrl:

 1reecord(db_header,
 2    {disk_version = ?LATEST_DISK_VERSION,
 3     update_seq = 0,
 4     unused = 0,
 5     fulldocinfo_by_id_btree_state = nil,
 6     docinfo_by_seq_btree_state = nil,
 7     local_docs_btree_state = nil,
 8     purge_seq = 0,
 9     purged_docs = nil,
10     security_ptr = nil,
11     revs_limit = 1000
12    }).

update_seqは恐らくヘッダが書き換わることにインクリメントされていく値だと思います。fulldocinfo_by_id_btree_stateがドキュメントを格納するB+tree、docinfo_by_seq_btree_stateがドキュメントの最新シーケンス番号?のB+treeだと思います。このヘッダの各値は、今後読み進めていく中で確認していきます。

Conclusion

まずはデータファイルのヘッダの読み込み処理を読んでみました。Erlangはバイナリの読み込みが簡潔に書けるのが良いですね。