実装状況報告: Standaloneモードでの動作確認とトランザクションのサポート

このところ実装に注力してブログの更新が滞ってしまっていましたが、PerlShellKoherent::DBがとりあえず動くようにはなりました。

当初の予定に加えて次の機能を実装することにしました。

現状では、Standaloneモードかつインデックスなしの環境にて、一応動作確認ができました。

Standaloneモードの実装

レンタルサーバ等では、独自にDBサーバを立ち上げてプロセスを走りっぱなしにできないことが多いと思います。そのような環境でも動作するようにという理由で、Client/Serverモードに加えてファイルのような感覚でopen/closeできるStandaloneモードを実装することにしました。

StandaloneモードとClient/Serverモードで処理を共通化するために、処理の異なる部分は抽象化して扱いました。例えばStandaloneモードで用いるスレッド間用のミューテックスとClient/Serverモードで用いるプロセス間用のミューテックスを抽象化してMutexクラスを作成し、それを継承したProcessMutexクラスとThreadMutexクラスを実装するなどしました。両モードで適切に動作するためのクラス設計(Client/Serverモードでは一つしか存在しないものが、Standaloneモードは同時に複数プロセスで複数存在する場合にうなく対処するなど)に少々わずらわされました。

トランザクションの実装

トランザクションの分離レベルは、SQLでいうところのSERIALIZABLEオンリーで実装しています。SERIALIZABLEのみのサポートでも、4段階の分離レベルの上位レベルは下位レベルを包括する*1ので仕様上は問題ありません*2

SAVEPOINTおよびROLLBAK TOはトランザクション入れ子で対応することにしたので、ROLLBACK TO (SAVEPOINT)をどう実装するかに書いたようなトランザクションとコマンドの両方のコミットログを作るのではなく、XIDに対応したコミットログのみを作成しています。なお、現状ではSAVEPOINT/ROLLBACK TOは実装できていません。

現状と今後

今後は、下記の順に実装を進めようと思います。括弧内は実装完了の予定時期です。

  • インデックス(10月末〜11月上旬)
  • Client/Serverモード(11月上旬)
  • デッドロックの対処(検知 or タイムアウト)(11月中旬)
  • SAVEPOINT/ROLLBACK TO(11月中旬)

どうやら、はてなに応募するのは11月中旬以降になってしまいそうです・・・。

テストの様子

どんな感じで動いているのか雰囲気だけでも伝わればと思い、実行しているテストの一部(Standaloneデータベースを使って、主にトランザクション周りをチェックしているテスト)を公開します。現状でこのテストは通っています。穴だらけな上に異常系のテストがまったくない*3ので、もっとテストも改良しなければいけませんが・・・。

use Test::More tests => 59;
use File::Path qw(rmtree);
use Koherent::DB::StandaloneDatabase;

use constant DATABASE_DIRPATH => 't/02.database';

##### initialize #####
my $test_name;

# 前回作成したテスト用データベースを削除
&rmtree(DATABASE_DIRPATH);

# データベースおよびデータベースオブジェクトの作成
Koherent::DB::StandaloneDatabase->create(DATABASE_DIRPATH);
my $database = Koherent::DB::StandaloneDatabase->new(DATABASE_DIRPATH);

# テーブルの作成
$database->create_table('members');
$database->create_table('groups');

my $member_array = [
{
    member_id => 1011,
    first_name => 'Yusuke',
    family_name => 'Nakata',
    age => 22,
    sex => 0,
    group_id => 1,
},
{
    member_id => 1234,
    first_name => 'Saki',
    family_name => 'Yamashita',
    age => 17,
    sex => 1,
    group_id => 2,
},
{
    member_id => 1999,
    first_name => 'Takashi',
    family_name => 'Miyagawa',
    age => 18,
    sex => 0,
    group_id => 2,
},
{
    member_id => 2112,
    first_name => 'Nana',
    family_name => 'Ishida',
    age => 21,
    sex => 1,
    group_id => 1,
},
{
    member_id => 2345,
    first_name => 'Chika',
    family_name => 'Yamazaki',
    age => 19,
    sex => 1,
    group_id => 1,
},
];

my $new_member = {
    member_id => 2555,
    first_name => 'Akira',
    family_name => 'Tanioka',
    age => 22,
    sex => 0,
    group_id => 1,
};

my $group_array = [
{
    group_id => 1,
    group_name => '大学生',
},
{
    group_id => 2,
    group_name => '高校生',
},
];

my $new_group = {
    group_id => 2,
    group_name => '中学生',
};

my $members = $database->table('members');
my $member;
my $groups = $database->table('groups');
my $iterator;

# データの挿入
$members->insert($member_array);
$members->insert($new_member);
$groups->insert($group_array);

##### iterate #####
$test_name = 'iterate';

$iterator = $members->order_by('member_id')->iterator;

$member = $iterator->next;
is($member->{'member_id'}, 1011, $test_name);
$member = $iterator->next;
is($member->{'member_id'}, 1234, $test_name);
$member = $iterator->next;
is($member->{'member_id'}, 1999, $test_name);
$member = $iterator->next;
is($member->{'member_id'}, 2112, $test_name);
$member = $iterator->next;
is($member->{'member_id'}, 2345, $test_name);
$member = $iterator->next;
is($member->{'member_id'}, 2555, $test_name);
$member = $iterator->next;
ok(!$member, $test_name); # end iterating

##### update #####
$test_name = 'update';

$members->update(sub{$_[0]->{'age'}++}); # 新学年
$iterator = $members->order_by('member_id')->iterator;

$member = $iterator->next;
is($member->{'age'}, 23, $test_name);
$member = $iterator->next;
is($member->{'age'}, 18, $test_name);
$member = $iterator->next;
is($member->{'age'}, 19, $test_name);
$member = $iterator->next;
is($member->{'age'}, 22, $test_name);
$member = $iterator->next;
is($member->{'age'}, 20, $test_name);
$member = $iterator->next;
is($member->{'age'}, 23, $test_name);
$member = $iterator->next;
ok(!$member, $test_name);

##### where and update/delete #####
$test_name = 'where and update/delete';

$members->where(sub{$_[0]->{'age'} > 22})->delete; # 卒業
$members->where(sub{$_[0]->{'age'} == 19})
->update(sub{$_[0]->{'group_id'} = 1}); # 進学
$iterator = $members->order_by('member_id')->iterator;

$member = $iterator->next;
is($member->{'group_id'}, 2, $test_name);
$member = $iterator->next;
is($member->{'group_id'}, 1, $test_name);
$member = $iterator->next;
is($member->{'group_id'}, 1, $test_name);
$member = $iterator->next;
is($member->{'group_id'}, 1, $test_name);
$member = $iterator->next;
ok(!$member, $test_name);

##### transaction #####
$test_name = 'transaction';

$database->begin;
$members->where(sub{$_[0]->{'member_id'} == 1999})->delete; # 退学処分
is($members->count, 3, $test_name);
$database->rollback; # 手続きミス
is($members->count, 4, $test_name);

# 2 connections
{
    my $database2 = Koherent::DB::StandaloneDatabase->new(DATABASE_DIRPATH);
    my $members2 = $database2->table('members');
    my $groups2 = $database2->table('groups');
    my $new_member2 = {
        member_id => 2828,
        first_name => 'Yusuke',
        family_name => 'Nanami',
        age => 19,
        sex => 0,
        group_id => 1,
    };

    ##### dirty read (insert - rollback) #####
    $test_name = 'dirty read (insert - rollback)';

    $database2->begin;
    $members2->insert($new_member2); # 外部入学
    
    is($members->count, 4, $test_name);
    is($members2->count, 5, $test_name);

    $members2->insert($new_member); # 手続きミス

    is($members->count, 4, $test_name);
    is($members2->count, 6, $test_name);

    $database2->rollback;

    is($members->count, 4, $test_name);
    is($members2->count, 4, $test_name);

    ##### dirty read (insert - commit) #####
    $test_name = 'dirty read (insert - commit)';

    $database2->begin;
    $members2->insert($new_member2); # 外部入学

    is($members->count, 4, $test_name);
    is($members2->count, 5, $test_name);

    $database2->commit;

    is($members->count, 5, $test_name);
    is($members2->count, 5, $test_name);

    ##### dirty read (delete - commit) #####
    $test_name = 'dirty read (delete - commit)';

    $database2->begin;
    $members2->where(sub{$_[0]->{'member_id'} == 1999})->delete; # 退学処分

    is($members->count, 5, $test_name);
    is($members2->count, 4, $test_name);

    $database2->commit;

    is($members->count, 4, $test_name);
    is($members2->count, 4, $test_name);

    ##### phantom read #####
    $test_name = 'phantom read';

    $database->begin;
    $groups->insert($new_group); # 中等部設立

    $database2->begin;

    # dirty read
    is($groups->count, 3, $test_name);
    is($groups2->count, 2, $test_name);

    $members->insert({
            member_id => 3000,
            first_name => 'Yuki',
            family_name => 'Nanami',
            age => 12,
            sex => 1,
            group_id => 3,
        }); # 中等部入学

    # dirty read
    is($members->count, 5, $test_name);
    is($members2->count, 4, $test_name);

    $members->update(sub{
            my $member = shift;

            $member->{'age'}++;

            $member->{'group_id'} = 1 if &is_college_student($member);
            $member->{'group_id'} = 2 if &is_high_school_student($member);
            $member->{'group_id'} = 3 if &is_junior_high_school_student($member);
        }); # 新学年 & 進学
    $members->where(\&graduated)->delete; # 卒業

    # dirty read
    is($members->where(sub{$_[0]->{'member_id'} == 1234})
        ->row->{'age'}, 19, $test_name);
    is($members->where(sub{$_[0]->{'member_id'} == 1234})
        ->row->{'group_id'}, 1, $test_name);
    is($members2->where(sub{$_[0]->{'member_id'} == 1234})
        ->row->{'age'}, 18, $test_name);
    is($members2->where(sub{$_[0]->{'member_id'} == 1234})
        ->row->{'group_id'}, 2, $test_name);

    $iterator = $members2->where(sub{$_[0]->{'group_id'} == 1})->order_by(sub{
            my $member1 = shift;
            my $member2 = shift;

            ($member1->{'sex'} <=> $member2->{'sex'}) * 2
            + ($member1->{'member_id'} <=> $member2->{'member_id'});
        })->iterator; # 大学生のメンバーを男性/女性別にmember_id順に取得
    $member = $iterator->next;
    is($member->{'member_id'}, 2828, $test_name);
    $member = $iterator->next;
    is($member->{'member_id'}, 2112, $test_name);
    $member = $iterator->next;
    is($member->{'member_id'}, 2345, $test_name);
    $member = $iterator->next;
    ok(!$member, $test_name);

    $database->commit;

    # unrepeatable read
    is($groups2->count, 2, $test_name);
    is($members2->count, 4, $test_name);
    is($members2->where(sub{$_[0]->{'member_id'} == 1234})
        ->row->{'age'}, 18, $test_name);
    is($members2->where(sub{$_[0]->{'member_id'} == 1234})
        ->row->{'group_id'}, 2, $test_name);

    # phantom read
    $iterator = $members2->where(sub{$_[0]->{'group_id'} == 1})->order_by(sub{
            my $member1 = shift;
            my $member2 = shift;

            ($member1->{'sex'} <=> $member2->{'sex'}) * 2
            + ($member1->{'member_id'} <=> $member2->{'member_id'});
        })->iterator; # 大学生のメンバーを男性/女性別にmember_id順に取得
    $member = $iterator->next;
    is($member->{'member_id'}, 2828, $test_name);
    $member = $iterator->next;
    is($member->{'member_id'}, 2112, $test_name);
    $member = $iterator->next;
    is($member->{'member_id'}, 2345, $test_name);
    $member = $iterator->next;
    ok(!$member, $test_name);

    $members2->insert({
            member_id => 3333,
            first_name => 'Ai',
            family_name => 'Sasaki',
            age => 16,
            sex => 0,
            group_id => 2,
        }); # 高等部入学

    ok(!$members->where(sub{$_[0]->{'member_id'} == 3333})->exists, $test_name);
    ok($members2->where(sub{$_[0]->{'member_id'} == 3333})->exists, $test_name);

    $database2->rollback; # 不正発覚・入学取消

    ok(!$members->where(sub{$_[0]->{'member_id'} == 3333})->exists, $test_name);
    ok(!$members2->where(sub{$_[0]->{'member_id'} == 3333})->exists, $test_name);

    $database2->close;
}

$database->close;

sub is_junior_high_school_student{
    my $member = shift;
    $member->{'age'} >= 13 && $member->{'age'} < 16;
}

sub is_high_school_student{
    my $member = shift;
    $member->{'age'} >= 16 && $member->{'age'} < 19;
}

sub is_college_student{
    my $member = shift;
    $member->{'age'} >= 19 && $member->{'age'} < 23;
}

sub graduated{
    my $member = shift;
    $member->{'age'} >= 23;
}

まとめ

Standaloneモードにてひとまず動作しトランザクションも機能するようになりました。今後はインデックスやClient/Serverモードの実装などを順次行う予定です。

*1:13.2. トランザクションの分離より引用「リードアンコミッティドレベルを選択した時、実際にはリードコミッティドになり、リピータブルリードを選択した時、実際にはシリアライザブルになります。このように実際の隔離レベルは選択したレベルより厳密になることがあります。これは標準SQLでも許されています。この4つの隔離レベルについては、発生してはならない事象のみが定義され、発生しなければならない事象は定義されていません。」

*2:パフォーマンス上は問題があるかもしれませんが。

*3:ただし、これはStandaloneDatabaseのテストなのでwhereやorder_byなどのメソッドのテストは別途行っています。