maybe daily dev notes

私の開発日誌

MariaDBコントリビューション録その5 - MDEV-18873

前回のあらすじ

MDEV-24582に取り組むうちに、GDBが仲間に加わった。コードを実行しながら変数の中身も見えるすごいやつである。強力な仲間たちとともに、今日もIssueに立ち向かう。

tmokmss.hatenablog.com

とはいえ、とりあえず気持ちを変えてみる

ここ最近取り組んでいるMDEV-24582はなかなかの難敵なので、修正方針についてメンテナに合意を得てから実装に着手することにしよう。修正を追えてPRを出したあとに根本的な指摘をされるのはお互いに避けたいだろう。

該当のチケットに方針に関するたたき台を投稿したので、しばし返信を待つ。そしてこのままではブログネタがなくなるので、今日は別のIssueに取り組むことにする。

今回のIssue

特定のクエリを実行した際に、MariaDBサーバーがクラッシュするというバグ。クラッシュした際のスタックトレースが添付されているので、これを参考に見ていこう。

[MDEV-18873] Server crashes in Compare_identifiers::operator or in my_strcasecmp_utf8 upon ADD PERIOD IF NOT EXISTS with empty name - Jira

まずは例によって、問題が再現するクエリを書いたテストケースを作成し、実行してみる。

# mysql-test/suite/innodb/t/MDEV-18873.test
ALTER TABLE t ADD PERIOD IF NOT EXISTS FOR `` (s,e);

# run test
./mysql-test-run.pl innodb.MDEV-18873 --manual-gdb

ADD PERIOD という命令は初見だと思ったら、MariaDB特有の機能らしい。正直ほぼMySQL == MariaDBだと思っていたのだが、まれによく相違点があって面白い。

原因の特定

Issueのスタックトレースから Compare_identifiers::operator() でエラーが起きていることが分かるので、そこにブレークポイントを配置する。

#3  <signal handler called>
#4  0x000056225dec77ca in Compare_identifiers::operator() (this=0x7fd1a807a41f, a=..., b=...) at /data/src/10.4/sql/vers_string.h:42
#5  0x000056225ded034f in Lex_cstring_with_compare<Compare_identifiers>::streq (this=0x7fd198005e80, b=...) at /data/src/10.4/sql/vers_string.h:91
#6  0x000056225e0ec019 in LEX::add_period (this=0x7fd198004960, name=..., start=..., end=...) at /data/src/10.4/sql/sql_lex.h:4363
...
struct Compare_identifiers
{
  int operator()(const LEX_CSTRING& a, const LEX_CSTRING& b) const
  {
    DBUG_ASSERT(a.str[a.length] == 0);
    DBUG_ASSERT(b.str[b.length] == 0);
    return my_strcasecmp(system_charset_info, a.str, b.str);
  }
};

デバッガで追うと、 DBUG_ASSERT(a.str[a.length] == 0); でエラーが発生することが分かる。DebugビルドとReleaseビルドで挙動が異なるのも、このためだろう。今回のクエリでは、おそらくテーブル t が存在しないために、 a がNULLポインタになる模様。このため、 a.str がSegmatation faultとなり、サーバーがクラッシュする。

Releaseビルドでは、 my_strcasecmp の中でエラーが発生するが、これも a がNULLであることに起因するようなので、同じ原因と言えるだろう。

対処方法 - 1st trial

a.str を参照する前に、 a がNULLかどうか確認するのが最低限の対応となるだろう。以下のような判定を追加した。これまでは str がNULLの場合は必ず実行時エラーになっていたはずのため、この判定を追加しても既存の動作に悪影響はないはずだ。

  bool streq(const Lex_cstring_with_compare& b) const
  {
-    return Lex_cstring::length == b.length && 0 == Compare()(*this, b);
+    return Lex_cstring::length == b.length && str != NULL && 0 == Compare()(*this, b);
  }

やや対症療法的な感じはするが、シンプルで機能する修正ではあり、実際今回のエラーは解消する。一旦PRを作成して、レビュワーに確認してみることにする。

github.com

レビュー

半日程度でレビューが返ってきた。早い。実は今回のレビュワーは全く面識のないメンテナである。この方の視点では、謎の日本人からパッチが送られてきても、修正が妥当なのか不安に思うだろう。レビューのコメントも、修正の妥当性を説明することを求めているようだ。

たしかに言われてみれば、修正してはみたもののよくわからない点も多々あることに気付かされた。グローバルの強い開発者にコメントをもらえるのはOSSコントリビューションの醍醐味の一つと言える。その中で自身の理解の解像度を高めていけるのだから、これはとても良いことだ。

時間を割いてくれたレビュワーに感謝、不完全なPRを出した自分を陳謝しつつ、再度調査をしてみよう。

次回に続く。

C# 開発を始める on Visual Studio Code + MacOS

はじめに

自分は今までC#Windows上のVisual Studioか、Unityでしか開発したことがない。この2つはいずれも開発環境が完全自動で整備されるので、非常に楽に開発を始めることができたものだ。しかし、今回はVisual Studio Code、しかもMacで開発したいので、手動で環境を整備する必要があった。この手順をまとめる。

インストール

インストールすべきは以下の2つ:

このページが公式原典なので、最新の情報はそちらを参照すること。

ディレクトリ構成

C#リポジトリは、基本的にはソリューション→プロジェクトの入れ子構造を取る。これらは、以下のコマンドで作成することができる。

dotnet new sln
dotnet new <プロジェクトタイプ> --name=<プロジェクト名>
dotnet sln add <プロジェクト名>

プロジェクトはビルドの単位で、例えば作りたいアプリが1つのプロジェクト、その単体テストがもう1つのプロジェクト、といった感じ。ソリューションはプロジェクトを束ねる単位。MSBuildでビルドするために必要な設定ファイルと考えても差し支えない。この辺りに詳細が書いてある

dotnet new コマンドは、テンプレートに沿ってプロジェクトの雛形を作成するコマンド。プロジェクトタイプはここを参考に指定する。

次に sln add コマンドで、作成したプロジェクトをソリューションに追加している。 この辺りをGUIで操作できるのがVisual Studioの良さだったんだなと実感する。とはいえCLIでもそれほど大変ではないことが分かった。

この状態でVSCodeをリロードすると、C# Extensionが良い感じに設定を生成してくれて、ビルドやデバッグができるようになる。

ちなみにこれよりも具体的なディレクトリ構造については、C#は特にデファクトスタンダードもなく自由にして良い模様。とりあえず src ディレクトリに全て入れていくようにする。困ったらまた考えよう。

テストプロジェクトの追加

これを参考にC#ではテストが別のビルド単位となるので、新規プロジェクトとして追加する。

dotnet new xunit --name=test
dotnet sln add test
cd test
dotnet add reference ../<テスト対象>/<テスト対象>.csproj

単体テストフレームワークは、 xunit, mstest, nunit のうちどれかを生成できるが、市井の情報を見る限りはxunitが一番流行っているらしい。

テストは以下のコマンドで実行できる:

dotnet test

Visual Studioのようにテスト結果をGUIで表示できないか調べたが、VSCodeだと現状できない模様。改めて考えると自分はNode.js開発するときもCLIでテスト結果を見ていたので、ここは妥協する。

これで一通り開発の準備が整った。

余談: C#のバージョン

ここまでC#のバージョンを全く意識してなかったのだが、 .NET 6.0 ではC# 10がデフォルトで利用されるらしい。

C# language versioning - C# Guide | Microsoft Learn

自分が最後に触ったのはUnity 2019の頃で、そのときはC# 6とか7だった記憶。ずいぶんと進んだものだ。最近の更新履歴はこの辺りから確認できる

ちなみにテンプレートを console にすると、以下のようなファイルがエントリポイントとして生成される。クラスもなにもないのでびっくりするが、C# 9.0からクラス定義なしの記述がサポートされたらしい

// See https://aka.ms/new-console-template for more information
Console.WriteLine("Hello, World!");

まとめ

上記の手順で、C# on VSCode + Macの開発環境が整う。Visual Studioとは違ってCLIベースなのでややつらいが、言語ごとにエディタをインストールするのもまたつらいので、一旦我慢して使ってみることにする。

リアル脱出ゲーム 初参加 Lessons learned

はじめに

先日新宿近辺のリアル脱出ゲームに行ってきた。めちゃくちゃ面白かったし、また行きたい。次こそ良い成果を残せるように、今回得られた教訓をまとめる。

怪しげな雑居ビルの中で開催された

教訓

基本的にゲームマスターは人の盲点を突いてくる。盲点をなくすには、普段無意識で行っている脳内枝刈りを見直す必要があるぞ。

1. 一度使った小道具は再利用されるものと心得よ

一度使った手がかりは忘れがち。でもそこが盲点です!

一度使った手がかりを再利用することは普通にある。テレビゲームの常識だと大抵一度使ったキーアイテムは捨てても良いので、まさに盲点になりがち。脱出ゲームにおいては、常に頭の片隅に置いておくか、むしろ再利用を第2候補くらいで疑ってかかるべき。

2. 掛け算が潜んでいるぞ

ばつ印がでてきたら、アルファベットのXやバツはさることながら、乗算記号も疑うべし。文字列の中に急に出てくるとパッとは気づけないので、ゲームマスターに好まれてそう。

あとはN桁のシリンダー錠など数字の暗証番号が頻出するので、ただ数字をN個羅列するよりはひねりを持たせたいのかも。あるいは、僕のように筆算だけが得意な人間のため、均等に活躍の機会を生み出すための工夫なのだろう。

多分横棒―を減算記号、十を加算記号と読ませるような問題も今後は出てくるだろう。同様に対応したいところ。

3. 物理的に不可能な仕掛けはない

バーチャルのゲームではないので、物理的に不可能な仕掛けはありえない。このことは時に大きなヒントとなる。例えばそもそも謎解きの結果をどこにどのように入力すればよいのかすら分からない場合、物理的に検出が不可能・困難な方法は選択肢から外すことができる。

この時、電子工作で入手可能なセンサーを知ってると仕掛けを想像できるだろう。メタな推論だと萎えるかもしれないが、手段を選べるほど余裕はないことを心得よ。

akizukidenshi.com

4. 形や大きさが一致するペアを探せ

何かアイテムが登場したら、その形や大きさに一致するペアが周りに存在しないか確認せよ。大抵、対応する箇所にはめ込むとか、そのような仕掛けがあることが多い。

5. 背景や装飾に仕掛けを溶け込ませるテクニック

手がかりを背景や装飾に溶け込ませて、わかりづらくする手法がある。 このテクニックを知っていれば、逆に背景から手がかりを手早く見出すことができるかもしれない。

これはほぼズルいクイズのやり口なので、たつなみさんのTwitterを見て訓練すると良いだろう。

以上。今日の自分は昨日の自分よりも強くなっている。次回の脱出に期待。

MariaDBコントリビューション録その4 - GDBをVSCodeから使う

前回のあらすじ

MDEV-24582 は難敵だ。前回の成果は修正箇所を特定する方法を特定したのみ。今回はデバッガで処理を追いながら修正対象を見つけていく。

tmokmss.hatenablog.com

GDBの導入

大規模なコードベースの挙動を理解するためには、実際に実行してデバッガで処理を追うのが手っ取り早い。MariaDBC++ではGDBというデバッガが人気なので、まずはこれを使えるようにする。

CLIによるやり方は、この記事が詳しい。ちなみにGDBはここまで環境構築できていれば既に入っているはずで、明示的にインストールする必要はない。

nayuta-yanagisawa.hatenablog.com

かいつまんで書くと、まず mysql-test-run.pl--manual-gdb オプション付きで実行する。

./mysql-test/mysql-test-run.pl innodb.MDEV-24582 --manual-gdb

すると、以下のような表示が出てくる。ここに書いてあるコマンド (gdb -x ...) を別のシェルで実行すれば、gdbのコマンドを入力できるようになる。

To start gdb for mysqld.1, type in another window:
gdb -x /home/ec2-user/MariaDB-server/bld/mysql-test/var/tmp/gdbinit.mysqld.1   /home/ec2-user/MariaDB-server/bld/sql/mysqld

gdbのコマンドはこのサイトなどにまとめられているが、何分CLIは苦手なのでGUIでやりたい。特にVSCodeからgdbを操作できれば、非常に便利である。

VSCodegdbを扱うためには、vGDBという拡張機能を利用すれば良い。 (他もいろいろ試した中でこれだけは必要十分なオプション指定ができた。)

marketplace.visualstudio.com

launch.json には以下を記述する。注意点として、program には↑のgdbコマンドの第3引数のパスを、 debuggerArgs-x のオプションをを転記する。これはもともとできなかったが、vGDBの開発者に頼んだらすぐに実装してくれた。

{
    "version": "0.2.0",
    "configurations": [
        {
            "type": "vgdb",
            "request": "launch",
            "name": "C/C++ Debug Launch",
            "debugger": "gdb",
            "program": "./bld/sql/mysqld",
            "debuggerArgs": ["-x", "/home/ec2-user/MariaDB-server/bld/mysql-test/var/tmp/gdbinit.mysqld.1"]
        }
    ]
}

これで、VSCodeからデバッガを操作できるようになる。デバッグするときは、まず mysql-test-run.pl --manual-gdb を実行してから、VSCodeからデバッガを起動するようにすること。

ちなみにDEBUG CONSOLEを使えば、任意のgdbコマンドを実行することもできる。例えば関数を実行した評価結果を見たいような場合に使える。

これで装備は整ったので、いよいよ処理を追っていこう。 なお、mysql-test/suite/innodb/MDEV-24582.testには以下のテストコードを書いてある。今回のバグが再現する最低限のコード。以後、このテストコードを使ってデバッグを行う。

CREATE TABLE t1 (a VARCHAR(3), v VARCHAR(3) AS (CONCAT('x-',a)) VIRTUAL);
INSERT INTO t1 (a) VALUES ('foo');

処理を追う

とりあえず特定すべきは、virtual columnの値を計算している部分だろう。INSERTする文字列の長さでValidatonするためにはまず挿入する値を知る必要があるので、判定ロジックを追加するとしたらそれ以後になるはずだ。

まずは前回示唆された TABLE::update_virtual_fields 関数内にブレークポイントを置き、そこから周辺を調べることにする。実際この関数は Compute values for virtual columns used in query という説明も書かれており、非常にあやしいのだ。

色々変数の中身を見ていると、まず気になったのは vfieldfield_length。これは何の長さだろうか。実験してみると、どうやらカラム v の型の文字列長だということが分かった。以下はテーブル定義を VARCHAR(4) にしてみたときの図。field_length も4になっているので、そういうことだろう。つまり、Validationするときは、この値と書き込む値の長さを比較すれば良いということ。必要な値の片方が分かったのは大きい。

次は元の目的に立ち返って、virtual columnの値を計算している部分を特定しよう。しかし、TABLE::update_virtual_fields 関数の動きをつぶさに追ったのだが、なんとそのような計算を実行している様子はなかったのだ。コードはそれっぽく見えるが、実際はほとんどの分岐に入ることがなくほぼ何もしない関数だった。一旦は他のところを見てみよう。… どこから見ようか?

コールスタックから攻める?

コールスタックを見ると、この関数に至った関数呼び出しの流れが分かる。各スタックをクリックすることで、スタックを自由に行き来できる。これを参考にして、関連するコードを探そう。

しかし、結果的にはこのアプローチはうまくいかなかった。幅優先探査的なアプローチで一通り追ったが、目的の箇所を特定するには各関数呼び出しを深く追う必要があり、効率が悪い。何かよりボトムアップなアプローチがないだろうか。

CONCATから攻める?

再現に使っているテーブルでは、Virtual columnとして CONCAT 関数を用いている。virtual column の値を計算する際は、必ずCONCATの計算がされるはずなので、CONCAT計算のコードを特定すれば手がかりになりそうだ。

CREATE TABLE t1 (a VARCHAR(3), v VARCHAR(3) AS (CONCAT('x-',a)) VIRTUAL);

CONCATでgrepしてみよう。

ここで出てきた Create_func_concat という関数に当たりをつけさらにコードを追うと、 Item_func_concat というクラスから、 item_strfunc.cc というファイルに行き着いた。このファイルは This file defines all string functions ということで、今回探している文字列連結CONCATの処理も実装されていそうだ。正直どの関数が呼ばれるかコードからはよくわからないので、すべての関数にBreakpointを配置する。これには次のgdbコマンドを使う。

rbreak sql/item_strfunc.cc:.

が、駄目……っ! 文字列を連結するような処理が引っ掛かることはなかった。

その後も色々と考えて手を尽くしたものの、有益な手がかりは見つけられず、今日中にブログを書き終えたい私は焦りが募るばかり。

ふと思いだす

ここで、師が意味ありげなことをつぶやいていたのを思い出した。

SELECT時に計算、そういうのもあるのか… たしかに上のテストコードではSELECTを発行していないので、計算処理を見つけられなかったのは合点がいく。ためしにSELECT文も含めてみる。

CREATE TABLE t1 (a VARCHAR(3), v VARCHAR(3) AS (CONCAT('x-',a)) VIRTUAL);
INSERT INTO t1 (a) VALUES ('foo');
SELECT * FROM t1;

…ビンゴ!SELECTクエリの処理中に、TABLE::update_virtual_fields 関数の中で先程目をつけていた item_strfunc.ccItem_func_concat::val_str 関数が呼ばれた。なぜテストコードからSELECTを省いてたか考えると、再現には影響しないものと勝手に思い込んでいたためだ🤦  思い込みは本当に良くない(教訓。)

ということで、計算処理はSELECTで走ることが分かった。こうなると、当初想定していた、INSERT時にカラムの最大長と挿入する値の長さを比較してエラーを出す方法は使えない。今一度方針を立て直す必要がありそうだ。

INSERT時にエラーを吐くには、今までやっていなかったvirutal columnの値計算を追加することになる。大きな変更となるが、本当に設計・実装できるだろうか?

次回へ続く。

MariaDBコントリビューション録その3

前回のあらすじ

3つ目のIssueは強敵だったが、新しく覚えた「perrorを手がかりに調べる」技でどうにか解決した。__builtin_expect も仲間に加わり、tmokmssの冒険はまだまだ続く。

tmokmss.hatenablog.com

今回のIssue

[MDEV-24582] INSERT silently truncates too long value for a virtual column without warnings or errors - Jira

Generated columnの文字列長が長すぎる場合、エラーも警告もなくTruncateされてしまうというバグ。

まずは再現

一旦DockerでMariaDBを立て、同クエリを打ってみる。

version: '3.8'
services:
  mysql:
    image: "mariadb:5.5"
    ports:
      - "3306:3306"
    volumes:
      - "mysql_data:/var/lib/mysql"
    environment:
      MYSQL_ROOT_PASSWORD: password
      MYSQL_USER: admin
      MYSQL_PASSWORD: password
volumes:
  mysql_data:
CREATE TABLE t1 (a VARCHAR(3), v VARCHAR(3) AS (CONCAT('x-',a)) VIRTUAL);
INSERT INTO t1 (a) VALUES ('foo');

vx-foo になるので VARCHAR(3)の文字数を超過するが、たしかにエラーもなく INSERT に成功した。x-f と勝手にTruncateされる模様。

ちなみに同じクエリをMySQL 5.7に対して打つと、以下のエラーが帰ってくる。

Data too long for column 'v' at row 1

とりあえず、Issueに書いてあることは再現できた。

二分探査でバグ発生コミットを特定

と、いうのが定石なのだが、今回はうまくいかなかった。以下に顛末を記す。

二分探査するには、まずバグがないコミットとあるコミットを見つける必要がある。あるコミットは先程再現して見つかっているので、バグがないコミットの方を探そう。このため、適当にさかのぼってクエリを実行してみる。

5.5はダメなので、5.4以下を探していこう。ところで実はMariaDB 5.4というのは存在しない。経緯は謎だが、欠番となっている。このため、5.3から試していく。

調べるとこの人が5.3と5.2のイメージを作ってくれているので、利用しよう。 ちなみにさっきからDockerを使っているのは、MariaDBの昔のGitコミットではいつものビルドコマンドが使えず、必要なビルド方法を調べるのが面倒だったため。粗い検証方法ではあるが、それなりの参考にはなるはず。

結果的には、5.3でも5.2でも同様にバグが再現した。5.1はイメージが見つからず試せていないが、あとから知ったがそもそも virtual column は5.2から実装された機能だったらしい。

ここで疑うのは、そもそもこのバグ最初からあったんではということ。こうなるとバグの可能性があるコード片は簡単に絞り込めないため、難易度が跳ね上がる。一旦二分探査は諦めて、方針を改めよう。

MySQLのコードを参考にする

次に予想したのは、MySQLではバグが再現しないことから、MySQLでは同バグを修正したのではということ。つまり、MySQL側の修正コミットを特定できれば、それを参考にMariaDBのバグも修正できるかもしれない。

これも2分探査で見つけられるはずなので、まずはMySQLでバグが再現するコミットを見つけることを試みる。冒頭では5.7で再現しなかったので、MySQL 5.6にクエリを打つ。

ここで意外な事実が発覚。なんとvirtual columnはMySQLでは5.7から実装された機能だった。5.6では CREATE TABLE クエリに対して無情な構文エラーが返ってくる。

You have an error in your SQL syntax; check the manual that 
corresponds to your MySQL server version for the right syntax 
to use near 'AS (CONCAT('x-',a)) VIRTUAL)' at line 1

WikipediaによればMySQL 5.7は2015年にリリースされたものである。一方MariaDB 5.2は2010年なので、MariaDBMySQLとでは全く別の時期にリリースされたことが分かる。

つまり、2つは似たような機能ではあるものの、実態は全く別のコードで動いている可能性が高い。これだとMySQLのコードを参考にMariaDBのバグを直す方針も難しいだろう。どうする?

助けを求める

膨大なコードから手がかりを見つけることが困難なため、独力では詰みに近い。 師に指示を仰いだところ、見るべきコードが啓示された。

たしかに、今回の処理と関係してそうである。次回は、この辺りの処理をデバッガで追うことにしよう。

次回へ続く。

MariaDBコントリビューション録その2 - builtin_expect による分岐予測

前回のあらすじ

初のMariaDBコントリビューションを達成し気をよくした俺。順調に2つ目のIssueもマージされ、浮かれ気分で次なる敵に挑むのであった。

tmokmss.hatenablog.com

今回のIssue

[MDEV-28599] EXCHANGE PARTITION on view causes ER_CHECK_NO_SUCH_TABLE instead of ER_WRONG_OBJECT - Jira

ALTER TABLE v EXCHANGE PARTITION p0 WITH TABLE t2 というクエリが何らかの原因で失敗した際のエラーメッセージを、よりわかりやすいものに変更するという目的のようだ。

Fix Version/s の中で最も古いのは 10.3 なので、そのブランチから作業を始めれば良いらしい。

また、事前情報として perror というコマンドも教えてもらっている。

修正箇所の特定

とりあえず、現行のエラーコードで perror してみる。

$ extra/perror 1177
MariaDB error code 1177 (ER_CHECK_NO_SUCH_TABLE): Can't open table

ER_CHECK_NO_SUCH_TABLE というのがエラー名の模様。おそらくこれが呼び出されている部分が対象の修正箇所なのだろうと予想し、grepする。

数カ所発見。大量に出てくるとかではなくてホッとした。この中から対象箇所を特定するため、Issueを読み返す。

まず EXCHANGE PARTITION というクエリ では、あるテーブルから他のテーブルに指定したPARTITIONを移動する機能らしい。で、この移動先のテーブルがTABLEでなくVIEWだとエラーになるが、その時に表示されるエラーがユーザーからは原因が分かりづらいと。 おそらくは 移動先がVIEWかどうかチェックしている箇所がありそうなので、それを探す。

それらしいのはここしかないのだけど、Viewというワードが出てこないので自信が持てない。当初の予想とは異なり、まだVIEWか否かのチェックは実装されていないのかも? とりあえず、ここのエラーコードを変えてからテストを実行してみよう。

上のgrep 結果から、対応するテストコードが mysql-test/main/partition_error.test であることは特定済みなので、次のコマンドでテストを実行できる。

$ cd bld/mysql-test
$ ./mysql-test-run.pl main.partition_error

main.partition_error                     [ fail ]
        Test ended at 2022-05-29 03:23:46

CURRENT_TEST: main.partition_error
mysqltest: At line 20: query 'ALTER TABLE t1 EXCHANGE PARTITION p0 WITH TABLE v1' failed with wrong errno 1178: 'The storage engine for the table doesn't support (null)', instead of 1177...

The result from queries just before the failure was:
drop table if exists t1, t2;
#
# Bug#60039: crash when exchanging a partition on
#            nonpartitioned table with a view
#
CREATE TABLE t1 (a int);
CREATE OR REPLACE VIEW v1 AS SELECT * FROM t1;
ALTER TABLE t1 EXCHANGE PARTITION p0 WITH TABLE v1;

エラーコードを 1177から1178 に変えてみたのだが、期待通りテストに失敗してくれた。どうやらここが該当のエラーコードを出力しているところということで間違いないらしい。これで修正箇所を特定できた。

修正方針の検討

最終目標はIssueに書いてある通りで、移動先にVIEWが指定された場合は 1347: 'test.v' is not of type 'BASE TABLE' というエラーを表示すること。 *1

そのためには、次のようなことをやれば良さそう。

  1. 既存のエラーを吐く条件 if (unlikely(!part_table || !table)) の意図を考える
  2. コード上のどこでViewを判定するか考える
  3. 入力がViewかどうか判定する方法を調べる

前のIssueと比べると全然自明じゃない…!とりあえず1から。

if (unlikely(!part_table || !table)) だが、これはただのnullチェックをしているだけ。 現行のエラーはこの条件から生じているので、ナイーブに考えれば、ここの ER_CHECK_NO_SUCH_TABLE を1347に書き換えれば良さそう。しかしそれでは他のケースでも1347エラーになる場合が生じそうで、都合の悪いことがわかる。つまり、移動先がViewであることとtableがnullであることは必要十分条件じゃないだろうという予想。ここは正直コードだけから判断するの至難なので、予想だけしておいて判断はPRレビューに任せることにする。

ということで、この部分はそのまま放置して、ここよりも前の処理で移動先がViewであるかどうかのチェックをする方針に。最悪レビュー受けてからまた調整しましょう。ちなみに今回コードはこの辺りを見ている

上記の考察を踏まえて、以下のコードを追加した:

...
  // これを追加してみた ifの条件はTBD
  if (swap_tableがViewなら)
  {
    my_error(ER_WRONG_OBJECT, MYF(0), table_list->db.str,
              swap_table_list->table_name.str, "BASE TABLE");
    DBUG_RETURN(TRUE);
  }

  // 既存のValidation
  if (unlikely(check_exchange_partition(swap_table, part_table)))
    DBUG_RETURN(TRUE);
...

変数 swap_table_list は、移動先のテーブルの情報が入った構造体 (TABLE_LIST) のようなので、これを見てViewかどうか判定すれば良さそう。また、 ER_WRONG_OBJECT は既存のコードを参考にそれっぽく穴埋めして書いている。これも帰納的プログラミング。

tmokmss.hatenablog.com

これで2も完了。あとは3だけで、if文の条件を考えれば良い。 しかしこれもコード読むだけだと正しい判断をするのは難しいと予想*2。一旦少なくともテストケースはパスするコードにしておき、PRレビューで担保してもらうことにする。ちなみにここの条件を間違えていたら、今まで問題なかったクエリでもエラーを吐くことになる。なかなか重要な変更になるぞ…

とりあえずで提出したif条件はこれ。swap_table_list->viewがnullでなければ、移動先がViewであるとみなす。他の箇所でも似たような判定をしていたので、それを流用しただけ。ちなみに詳しい人は「なぜ likely ?」と思うかもしれないが、この時点では likely(x)で xが0でないときに真を返すものだと勘違いしていた🤦 。

if (likely(swap_table_list->view))

これで PRを作成。レビューを待つことにしよう。

レビューを受ける

安定安心の nayuta-yanagisawa によるレビュー。以下のようなレビューを受けた。

  1. ifの条件は問題ない
  2. テストケースがいまいちだから新たに追加しておいて
  3. likelyじゃなくunlikelyじゃない?

1はこれで安堵できる。理屈としては、変更の影響範囲はごく狭いため、自動テストによる検証で担保する方針でOKとのこと。

2は順当に追加。たしかによく見ると既存のテストは2つのエラー要因があり、今回のテストケースとしてはふさわしくないものとなっていた。

3が問題のlikely。そもそもの定義は以下のコードで、自分は当初 __builtin_expect(((x) != 0),1)((x) != 0) == 1 という意味だと早とちりしていた! __builtin_expect のようなよくわからない関数はスルーせずしっかりと調べるべきである (教訓)。

#define likely(x)    __builtin_expect(((x) != 0),1)
#define unlikely(x) __builtin_expect(((x) != 0),0)

__builtin_expect の正体はこれ。 評価結果が真と偽のどちらになる可能性が高いかをコンパイラに教えることで、分岐予測による性能を高めることを狙うものであった。今回は swap_table_list->view は偽である可能性が高いので、 unlikely が正しい。英語を考えると likely は起こりやすい、 unlikely は起こりにくいなので、たしかに明らかでした。

ということで全て修正して、無事マージされたのであった 🎉

まとめ

今回のIssueも無事にクローズできた。しかし難易度は徐々に上がっていることを感じる。次回もVSCodeと共にあらんことを。

*1:ちなみにエラーコードを変更する程度であれば、破壊的変更とはみなさないとのこと。

*2:この辺りはMariaDBのドキュメントや資料、あるいはExpert MySQLを読むと分かるかも、とのこと。

DynamoDB、シングルテーブルにするか否か

はじめに

DynamoDBを使っていると、とかくテーブルは1つにまとめるべきという声や、複数テーブルからシングルテーブル設計に移行したという事例を耳にすることがあります。 しかし、その理由を聞いてみると、性能のためだったり管理を簡単にするためだったり、人により異なる印象です。 NoSQLにおける非正規化してデータをもつプラクティスは理解しつつ、その域を超えて全く無関係なItemを1テーブルにまとめる場合もあるようです。

私自身このトピックについて混乱していた中で、先日 The What, Why, and When of Single-Table Design with DynamoDB というブログを見つけました。 それを読んだ上で考えると、割と理解が整理できたので、この記事にまとめてみます。

なお私はDynamoDBの運用経験がまだ十分にあるわけではないので、勘違いや考慮漏れなどあるかもしれません。ツッコミお待ちしております。

シングルテーブル設計とは

あるアプリケーションが使うDynamoDBのテーブル数を1つに集約する設計方針です。 dynamodb single table などでググると、この設計に関する多くの記事が見つかるでしょう。

ちなみに AWS公式のドキュメント にも、このように記載されています。

In general, you should maintain as few tables as possible in a DynamoDB application. (中略) A single table with inverted indexes can usually enable simple queries to create and retrieve the complex hierarchical data structures required by your application.

こちらは as few table as possible という表現ですね。

DynamoDBは基本的にスキーマレスであり、Itemのスキーマを制限するものはPK, SK, GSI, LSIカラム名・型とTTLカラム名のみです。つまり、それらだけ一致させれば、任意のデータセットを1つのテーブルに集約することが可能です。現実ではカラム名PK / SK など汎用的に命名し、型も文字列型にすることが多いでしょう。そうした条件が満たされていれば任意の2テーブルは1つに集約可能なため、理論上あらゆるアプリケーションはシングルテーブル設計を採ることができるわけです。

技術的にはどちらでも良いが片方を選ばなければならない時、人は悩みます。この記事ではシングルテーブル設計にするか否か、比較の観点をできるだけ網羅してまとめることを目指します。

シングルテーブル設計を検討する観点

観点1. データ取得の性能が改善する場合がある

これが最も定番の理由です。

DynamoDBのQuery APIを利用すると、同じテーブル・同じPartition Key(PK)のデータを、1回のAPIコールでまとめて取得することができます。 これにより、複数のテーブルにデータが散在している場合と比べて、少ない回数のAPIコールで必要な情報を取得でき、効率化できる場合があります。

このメリットはRDBMSのようなJOIN機能を持たないNoSQLに共通の観点であり、 非正規化 という言葉でも有名です。 次のブログなどに詳しい解説があります ( Creating a single-table design with Amazon DynamoDB 。) RDBMSのテーブル設計とは全く異なる独特な考え方が必要のため、慣れないととっつきづらい部分ですが、最近は公式ドキュメントでも多くのユースケースにおけるデザインパターンのベストプラクティスが紹介されているため、少し身近な存在にもなりつつあります。

しかしながら、そもそもまとめて取得する要件がないようなアイテム同士は、一つのテーブル上に配置するメリットがあるとは言えません。 そのようなテーブル同士であっても、一つにまとめるメリットはあるのでしょうか。次を見てみましょう。

観点2. テーブル数が減ると、関連リソース数も減る

運用の都合によっては、DynamoDBテーブルに対して以下のような機能が必要になる場合があります:

  • 各種メトリクスの監視
  • テーブルアクセスに対する監査ログの記録
  • バックアップ
  • 別のストレージへのレプリケーション

これらは利用しているすべてのDynamoDBテーブルに共通で必要になることもあるでしょう。その場合はテーブル数に応じて、関連するCloudWatchのアラームやLambda、DynamoDB Stream、さらにはそれらを監視するアラームなど、管理すべきリソースが増えていきます。

リソースが増えることのデメリットとして、以下が挙げられます:

  1. 監視ダッシュボードやマネジメントコンソールが見にくくなる
  2. 構築が大変になる
  3. 固定費のかかるリソースがある場合は、コスト効率も悪化する

シングルテーブル、あるいはテーブル数を最小化するようにすれば、このリソース増大問題を回避・緩和することができます。実際、私が参加していたプロジェクトでも、この観点が主な理由で10個強のテーブルを1つに集約するようなリファクタを開始していました。

とはいえ2のデメリットについては、CDKなどのIaCを利用すれば比較的容易に必要なリソースを反復定義・デプロイできます。また、3のデメリットについても、サーバーレスのサービスを選べば、固定費が掛かるサービスは少ないでしょう。 このため、こうしたデメリットは許容できる場合も十分考えられます。

観点3. キャパシティ管理のしやすさ

DynamoDBをProvisionedモードで利用する場合、テーブルごとにキャパシティ (WCU, RCUの割当数)の管理が必要になります。

テーブルを一つにまとめるとキャパシティ管理の対象も一つになるので、管理が容易になる場合も多いでしょう。またテーブルをまとめることで、小規模なテーブルのキャパシティを大規模なテーブルのキャパシティの余剰分でまかなうことができるため、コスト効率の面でもシングルテーブルに分があります。この辺りの話は、マルチテナントのリソース共有による効率性の話にも通ずるものがありますね。

Resource sharing is a central benefit in multi-tenant systems. A multi-tenant system handles multiple workloads, such as work from multiple customers at once. This system can also handle low priority, non-urgent workloads along with high-priority, urgent workloads. A single-tenant system, on the other hand, handles workloads from a single customer. Fairness in multi-tenant systems

ただし、今のDynamoDBではキャパシティ管理が(ほぼ)不要なOn-demandモードもサポートされています。この場合は、単純にリクエスト数に応じた課金になるため、テーブルが分かれていても一つでもコストは同じです。従って、On-demandモードで運用する場合は、この観点のメリットは当てはまりません。

とはいえ、サービスの規模が大きい場合などワークロードによっては、On-demandモードよりもProvisionedモードを頑張って運用する方が安くなる場合があります。この分岐点を超えると、On-demandからProvisionedへの移行に踏み切ることもあるでしょう。いずれProvisionedに移行する可能性があるのなら、最初からそれに適したシングルテーブル設計にすべきという考え方もあるかもしれません。

観点4. IAM権限管理のしやすさ

シングルテーブルにすることでIAM権限管理が粗くなるんじゃない?と不安になるかもしれません。しかし、DynamoDBではテーブル単位だけでなく、行単位のIAMアクセス制御も可能です。

Using IAM policy conditions for fine-grained access control - Amazon DynamoDB

これにより、シングルテーブル設計であっても、サービスごとにテーブルを分けているような場合と同等の権限管理を実現できます。

実際のIAMポリシー例は、以下の回答も参考になります。ForAllValues:StringLikedynamodb:LeadingKeys を組み合わせることで、PK が特定のprefixをもつアイテムに対してのみアクセスを許可することができます。

stackoverflow.com

とはいえ、CDKの grantXX 系メソッド はテーブル単位でのみ設定可能、といったように既存のユーティリティがItemレベルの権限管理に十分対応していない事情もあります。この意味では、シングルテーブルにすることでIAM権限管理がやや煩雑になると考えても間違いではないでしょう。

まとめ

DynamoDBのシングルテーブル設計を採るか否かについて、検討ポイントを列挙してました。私自身は、性能に寄与しない場合は無理にシングルテーブルにしなくてよいだろう派ではありましたが、上記の考慮点をケアすると、その結論には至らない場合も多そうです。今後ともケースバイケースで対応していければと思います!

追記

最近(2023年末〜)はシングルテーブル設計に対する懐疑的な意見も多いようです。こちら↓の記事が分かりやすいです (元DynamoDBチームの方による記事)。

Single table design for DynamoDB: The reality — Momento

シングルメリットのデメリットとして、主には以下が挙げられています。

DynamoDBのドキュメントでは今もシングルテーブルが推奨されているため、人により意見の異なるトピックなのでしょう。

In the majority of cases, we recommend that you consider using a single table. source

どちらにしても偏った意見には惑わされず、臨機応変な判断をしていきたいですね。