OpenSearch調査

概要

OpenSearch(ElasticSearch)について調査して、知識を深める

OpenSearchって?

AWS と Elastic社が争って、AWSで提供するElasticSearchのサービスは、OpenSearchという名前になってる。

codezine.jp

Elasticsearch 7.10.2 から派生したので、ほとんどElasticSearchと一緒のことできる。

SQLが使える SQL を用いた Amazon OpenSearch Service のデータのクエリを行う - Amazon OpenSearch Service

Localで試してみたい

この記事通りにすると簡単に試せる!

OpenSearchをローカル環境でDockerを利用して構築する | DevelopersIO

ElasticSearchって?

こちらの記事読めばOK!

Elasticsearch入門 の記事一覧 | DevelopersIO

ちなみに、Elastic社が提供してるElasticStackというサービスの組み合わせがあって、これ使うとログ分析が捗るらしい(試したことしかない)

Elastic Stack = Elasticsearch、Kibana、Beats、Logstash | Elastic

競合は?

Algolia, Solr, Groonga など

どういう時に使う?

ElasticSearchは全文検索エンジンであるので、文字を検索したいときに使う。

N-gram形態素解析という二つの方法が利用できる。詳しい説明は下のサイトがわかりやすかった。

第6回 N-gramと形態素解析との比較 | gihyo.jp

ちなみに、MySQLPostgreSQLn-gramは既にできる。日本語の形態素解析Mecabを使えばできるっぽい。

なので、既にRDBMSを使ってる場合は、まずはRDBMSでやり切る方が先そう。

一方で、データが増えてきたり、スコアリングとか、ベクトル検索みたいな機能を使いたい時は良さそう?(RDBMSでもできるのかもしれないけど、知識がなくてわからない)

Elasticsearchの機能一覧 | Elastic

ベクトル検索だと、この記事がすごく勉強になる。ベクトルを保存して、類似度を検索できる。

Elasticsearchを用いて類似度ベクトル検索をやってみてわかったこと - ログミーTech

スコアリングは使ったことがあって、検索文字とどれだけ一致してるかを返してくれる。

function_scoreっていうのを使って、(PV数 x 1 + お気に入り数 x 10 + 一致度) でソートするみたいな順でデータを取得できたりできる。

RDBMSでなく、DynamoDBを永続化層と使っている場合は、ElasticSearchを検索に使う方法は結構記事が出てくるから一般的っぽい。

DynamoDBとElasticsearchをエンプラで本気で使ってみた2018年を非機能面で振り返る - Qiita

実際にIndexを作ってみよう!

Indexがテーブル設計。indexにmappingを設定するのだが、これが非常に重要となる。

とりあえずやってみる

Product {
  id: number
  manufactureId: number
  name: string
  description: string
}

Manufacture {
  id: number
  name: string
}

Event {
  eventMasterId: number
  userId: number
  timestamp: datetime
}

ざっくりやるとこんな感じ?

PUT /products
{
  "mappings": {
    "properties": {
      "productId": {
        "type": "integer"
      },
      // nameは完全一致も、部分一致でも検索したいので、text型とkeyword型の両方を作成する。
      "name": {
        "type": "text",
        "fields": {
          "keyword": {
            "type": "keyword"
          }
        }
      },
      // descriptionはkuromojiで日本語での形態素解析での検索を可能に
      "description": {
        "type": "text",
        "analyzer": "kuromoji"
      },
      "manufacture": {
        "properties": {
          "manufactureId": {
            "type": "integer"
          },
          // 企業名は完全一致での検索しかされないと判断して、keyword型のみ利用
          "name": {
            "type": "keyword"
          }
        }
      },
      // event情報も、productsのindexに紐づけることで、プロダクト検索を容易にする。
      // nestedを使うことで、それぞれの子要素に対する検索を可能にする
      "events": {
        "type": "nested",
        "properties": {
          "eventMasterId": {
            "type": "integer"
          },
          "userId": {
            "type": "integer"
          },
          "timestamp": {
            "type": "date"
          }
        }
      }
    }
  }
}

こんな感じで検索できる

GET /products/_search
{
  "query": {
    "bool": {
      "filter": [
        {
          "term": {
            "name.keyword": "商品A"
          }
        },
        {
          "nested": {
            "path": "events",
            "query": {
              "bool": {
                "filter": [
                  {
                    "term": {
                      "events.eventMasterId": "1"
                    }
                  }
                ]
              }
            }
          }
        }
      ]
    }
  }
}

懸念点

OpenSearchのDocumentには見つからなかったが、ElasticSearchのDocumentには、nestedの中のObjectが10000を上限とするように推奨されている。

なので、10000過ぎたらtimestampが古いものから削除するとかしても良いが、上手く検索できない。

パターン1

イベント定義ID毎に、最終イベント発生日と、イベントを発生したUserIdの一覧を登録するようにする。

これで、例えば直近でイベントAが発生した商品検索はできるし、ユーザーAがイベントAを発生した商品検索もできる。

だが、これだと指定の日時でイベントを発生した商品の検索ができない。

{
  "events": {
    "type": "nested",
    "properties": {
      "eventMasterId": {
        "type": "integer"
      },
      "lastEventedAt": {
        "type": "date"
      },
      "userIds": {
        "type": "keyword"
      }
    }
  }
}

パターン2

日付毎に、発生イベントの配列と、イベントを発生したユーザーの一覧を持たせる。

一定の期間毎にデータは削除していく。

これで、大体の検索はできそう。だが、指定期間にX回イベントが発生したかという検索はできない。

{
  "events": {
    "type": "nested",
    "properties": {
      "date": {
        "type": "date"
      },
      "eventMasterIds": {
        "type": "keyword"
      },
      "userIds": {
        "type": "keyword"
      }
    }
  }
}

パターン3

日付でgroupして、もう一回nestedにするようにしてみた。

おそらく、イベント発生を検索条件に入れる場合は、先に日付で絞り込みするだろうから、絞り込んだ後に、回数で検索できる。

思いつく感じはこれで網羅できたが、本当にこれでいけるのだろうか...

あと、そもそもイベントの更新があるたびにupdateするのは、非効率なので1日1回の更新とかになりそうだが、大丈夫?

{
  "events": {
    "type": "nested",
    "properties": {
      "date": {
        "type": "date"
      },
      "occurs": {
        "type": "nested",
        "properties": {
          "eventMasterId": {
            "type": "integer"
          },
          "count": {
            "type": "integer"
          },
          "userIds": {
            "type": "keyword"
          }
        }
      }
    }
  }
}

パターン4

新しくeventだけのindexを作っちゃって、Joinをする。

Join field type | Elasticsearch Guide [8.5] | Elastic

これが一番簡単そうだし自然だが、安易にリレーションを使うのはパフォーマンスの問題があるとのこと。

これだけならおそらく大丈夫そうだが、パフォーマンステストはしたほうが良さそう。

リレーショナルモデルを複製するために、複数レベルのリレーションを使用することはお勧めしません。各レベルのリレーションは、クエリ時にメモリと計算のオーバーヘッドを増加させる。検索性能を上げるには、データを非正規化するのがよいでしょう。

まとめ

色々調べたけど、実際にどんなmappingを作るのがベストか分からない...

更新頻度を考えつつ、どういう検索をしたいのかというのをちゃんと考えてから作る必要がありそう。

RedshiftなどのDWHとの使い分け

ElasticSearchは、リアルタイムに近い検索や全文検索が可能ですが、Redshiftほどに大量の時系列データを扱うことは厳しい。億単位のデータを瞬時に返そうとすると流石に勝てないやってなっちゃうっぽい。

一方で、RedshiftはInsertやUpdateが得意じゃない(ちなみにBigQueryはUpdateができない)ので、リアルタイムな更新が求められる場合は辛い。一方で、億単位のデータでも簡単にレスポンスが数秒で返ってくる。

AWS リソースメモ

目的

転職して、インフラ周りをガッツリ触るようになった。

色々知識が増えたので、忘れないようにメモしておく。

Tips

Terraform

  • 手動で、AWS コンソールから、リソースを作った場合は、Terraform import をすれば良い。  - やり方はterraformのドキュメントの一番下に書いてある。
  • 少なくとも、RDSといったストレージ系や、Iam、VPCなどは、terraform stateを分離しておいた方が良い。
    • 環境毎に使わないリソースとかできるし、terraform apply が早い
    • Remote Stateを設定しておけば、他のStateのoutputを利用できる

AWS Session Manager

  • session managerを使えば、SSHの設定とか、踏み台サーバーとかがいらない。
  • ポートのリダイレクトができるので、LocalからVPC内のRDSにアクセスするということも簡単。
  • EC2じゃなくて、Fargate でも使える!

Aurora

  • IAMを用いた認証ができる。パスワードが不要なので、流出する心配がない。  - ただ、IAMユーザーを作ったりしないといけないので、ちょっとめんどい。  - ユーザーが IAM 認証情報で Amazon RDS に接続できるようにする
  • Performance Insightsは神。とりあえず入れおくと良い。
    • SlowQueryはもちろんのこと、Lockの状況とかも確認できる。

API Gateway

  • Rest APIを使って、直接Kinesis streamに流したり、SQSにQueue入れたりできる。
  • Mock機能もあるので、公開されてるAPIを使った負荷テストしたい時に、代用のAPIを作ることができる。

S3

  • S3でサイトを作りたい時は、Static Website HostingをONにして使うと思う。が、これだと吐き出されるURLがhttpになる。  - なので、CloudFrontを前段に入れるのが多いが、その場合、もしIP制限したい場合はWAFが必要になる。
  • 一方で、Static Website HostingをOFFにしちゃえば、IP制限はS3の方でできるので、簡単!
    • 社内からしかアクセスしないし、そんな使われないけど、HTTPSにしないといけないって時(滅多にないと思うけど、今回あった)におすすめ。S3にHTML置いて、サイト作るなら、Static Website Hostingを使うって先入観が強すぎて、盲点だった...Storybookのpreview環境作る時に使ってたのに...

Kinesis Stream

  • Kinesisには、Aggregationという考え方がある
  • Aggregationした後は、De-Aggregationしないといけない。
    • こっちは、NodeとかGoとかのライブラリがある。
    • Kinesis Firehoseを使って、Kinesis streamのデータを、各リソースに渡す場合は、レコードの集約を解除してくれるので便利!
  • Kinesis StreamはDynamoDBのレコード追加とかも、取得してくれるので便利!

Memory DB

  • 従来AWSでRedisを使うときは、ElasticCacheを使っていたが、MemoryDBというのものある。
  • MemoryDBは、データの耐久性があるから、RDSのように永続的なデータを保存して、読み取りできる。
    • 一方で、ElasticCacheと比べて、通常の書き込みが遅かったり、ClusterModeを強制される。
  • DynamodB+DAXの代替として検討したり、RDB+キャッシュRedisなシステムを MemoryDB に集約するといったケースで使う感じっぽい

rails config.force_ssl についてメモ

railsdoc.com

config.force_ssl = true

にすると、以下のようにActionDispatch::SSLというmiddlewareが有効になる

if config.force_ssl
  middleware.use ::ActionDispatch::SSL, **config.ssl_options,
    ssl_default_redirect_status: config.action_dispatch.ssl_default_redirect_status
end

やってることは以下

rails/ssl.rb at v7.0.2.2 · rails/rails · GitHub

request.ssl?がfalseの場合、httpsにリダイレクトする

if request.ssl?
   ...
else
   return redirect_to_https request unless @exclude.call(request)
   ...
end

Cookieにsecure 属性をつける

def flag_cookies_as_secure!(headers)
  if cookies = headers["Set-Cookie"]
    cookies = cookies.split("\n")

    headers["Set-Cookie"] = cookies.map { |cookie|
      if !/;\s*secure\s*(;|$)/i.match?(cookie)
        "#{cookie}; secure"
      else
        cookie
      end
    }.join("\n")
  end
end

HTTP Strict Transport Security (HSTS) headerを追加する。

# https://tools.ietf.org/html/rfc6797#section-6.1
def build_hsts_header(hsts)
  value = +"max-age=#{hsts[:expires].to_i}"
  value << "; includeSubDomains" if hsts[:subdomains]
  value << "; preload" if hsts[:preload]
  value
end

request.ssl? の中身はというと、Rack::Request::Helpers にあるっぽい。 rack/request.rb at v2.2.2 · rack/rack · GitHub

こんな感じで、headerとか、X-Forwarded-Forにも対応してる。

def ssl?
   scheme == 'https' || scheme == 'wss'
end

def scheme
    if get_header(HTTPS) == 'on'
        'https'
    elsif get_header(HTTP_X_FORWARDED_SSL) == 'on'
        'https'
    elsif forwarded_scheme
        forwarded_scheme
    else
        get_header(RACK_URL_SCHEME)
    end
end

def forwarded_scheme
    allowed_scheme(get_header(HTTP_X_FORWARDED_SCHEME)) ||
    allowed_scheme(extract_proto_header(get_header(HTTP_X_FORWARDED_PROTO)))
end

なので、AWSでよくある、HTTPS -> ALB -> HTTP -> EC2 といった通信の場合は、内部ではHTTP通信でもRedirectされない。

extconf.rbについて調べたことメモ

目的

RubyのgemのC実装を読む際に、extconf.rb周りでエラーが起きるのだが、そもそもこれが何するものかが良くわからなかったので調べた。

結論

これを読めば良い。 docs.ruby-lang.org

自分用のメモは間違えてるかもしれないので、注意してください。

自分用にメモ

Ruby の拡張ライブラリのための Makefile を作成するファイル。mkmfライブラリをrequireして、このrbスクリプトを実行して、Makefileが生成される。 ヘッダファイルの存在チェック、ライブラリの存在チェックなどextconf.rbで行う。

// gemspec にこんな感じでファイルを指定する。
spec.extensions    = ['ext/extconf.rb']

こうすると、gem install時に、make コマンドを実行して、実行環境に合わせてコンパイルしてくれる。

詳細はこのサイトに詳しく書いてあるので参考に。

gem install で C拡張をビルドする流れを追ってみた - sonots:blog

dir_config というメソッドがあるが、これを用いると、依存packageを実行する際のパスを設定することができる。

docs.ruby-lang.org

例えば、duckdbだと、gem install時に、以下のように設定することで、gem毎に参照するディレクトリを設定できる。

--with-duckdb-include=/duckdb_header_directory
--with-duckdb-lib=/duckdb_library_directory

あるいは、/duckdb_directoryの下に、includeとlibディレクトリがあって、includeの方にはheaderファイルがある、libの方には共有ライブラリがあるという状態なら以下の設定だけでいける。

--with-duckdb-dir=/duckdb_directory

homebrewだと、こんな感じで指定できるはず!

--with-duckdb-dir=$(brew --prefix duckdb)

もし、gem毎に設定する必要がなければ、CFLAGSとLDFLAGSを環境変数で設定しても参照できる。

CFLAGSとLDFLAGSについて

自信あまりないので、違ったらご指摘ください...

CFLAGS: Cのsourceファイルをbuildする時に使う(Cのコンパイラーに渡すフラグ)

コンパイラーにCのビルドしたい標準の場所以外にあるファイルの位置を教えてあげる時に使える。

LDFLAGSS: ld(リンカー)に渡すフラグ

ビルドする際は、複数の.cファイルを.soファイルにまとめている。

リンカーは複数のビルド済みのファイルを、くっつけて使えるようにする。

そのリンカーに、標準の場所以外にあるファイルの位置を教えてあげるときに、LDFLAGS -L{path} という形で使える。

Rails & mysql2 gem

Mac & homebrewを利用している環境で、Railsでmysql2のgemを利用しようとすると、毎回ld: library not found for -lsslというエラーが出る。

これは、下の記事にあるように、ライブラリが見つからないとのことなので、pathを通してあげればいい。

【Ruby】M1macでmysql2がインストールできないとき

といった感じで、個々人では解決できる。

ここで、もしexconf.rbを修正するとすると、以下のように解決できる。

Dynamically set Homebrew-installed OpenSSL flag by olivierlacan · Pull Request #1204 · brianmario/mysql2 · GitHub

// homebrewを使っていて、OSがdarwin = macの場合、LDFLAGSに値を追加している。
if RUBY_PLATFORM =~ /darwin/ && system("command -v brew")
  openssl_location = `brew --prefix openssl`.strip
  if openssl_location
    $LDFLAGS << " -L#{openssl_location}/lib" 
  end
end

例えば、LDFLAGS="-L/opt/homebrew/opt/openssl/bin" と設定すると、/opt/homebrew/opt/openssl/bin以下のライブラリを検索してくれる。

こうすることで、毎回$ bundle config --local build.mysql2 "--with-ldflags=-L/opt/homebrew/opt/openssl/lib"みたいに手動でやらなくてもエラーなくインストールができる。

現時点のmysql2最新版である0.5.3では、まだexconf.rbの修正がリリースされてないが、もう少ししたらここに悩むことは無くなりそう!

OSSチャレンジ Part7 (ruby-duckdb編)

目的

学んだことを忘れないようにメモ!

間違えたことを書いている可能性あるので、注意してください!

あと、全部教えてもらいながらやったことを書いてるだけなので、自分の力ではないです。

やりたいこと

ruby-duckdb の gemに、Result#columnsを実装したい!

github.com

実装環境構築

m1 mac の場合は、以下コマンドを叩くと、bundle installができる。

gem install duckdb -- --with-duckdb-include=/opt/homebrew/include --with-duckdb-lib=/opt/homebrew/lib

--with-duckdb-includeの代わりにCPATH、--with-duckdb-libの代わりにLIBRARY_PATH といった環境変数を使ってもいけた。

homebrewのバージョンとgemのバージョンをチェックしてズレてないか注意!(これにハマってテストが通らず困った...)

とりあえず一人で実装

[#168]Add DuckDB::Result#columns · okadakk/ruby-duckdb@bb31021 · GitHub

static VALUE duckdb_result_columns(VALUE oDuckDBResult) {
    rubyDuckDBResult *ctx;
    Data_Get_Struct(oDuckDBResult, rubyDuckDBResult, ctx);

    idx_t col_idx;
    # DuckDBのCのAPIを叩く。
    # https://github.com/duckdb/duckdb/blob/33c0cbee75c134464984a2751e1b615477153d2a/src/include/duckdb.h#L375
    idx_t column_count = duckdb_column_count(&(ctx->result));

    # 空配列を作成
    VALUE ary = rb_ary_new2(column_count);
    for(col_idx = 0; col_idx < column_count; col_idx++) {
        # https://github.com/duckdb/duckdb/blob/33c0cbee75c134464984a2751e1b615477153d2a/src/include/duckdb.h#L356
        const char* column_name = duckdb_column_name(&(ctx->result), col_idx);
        # char* ということは文字列が帰ってきてるので、Rubyの文字列型に変換してarrayに値を追加。
        rb_ary_store(ary, col_idx, rb_str_new2(column_name));
    }
    return ary;
}

アドバイスもらって実装

上のままだと、単なる文字列の配列しか返ってこないから不便ということで、クラスを返したほうがいいんではないか?とアドバイスをもらったので、自分でやってみた。

[#168]Add DuckDB::Column · okadakk/ruby-duckdb@8bd57b5 · GitHub

# 構造体を作って
struct _rubyDuckDBColumn {
    duckdb_type _type;
    const char *_name;
};

# 値を入れておいて
VALUE create_column(VALUE oDuckDBResult, idx_t col) {
    VALUE obj;
    obj = allocate(cDuckDBColumn);
    rubyDuckDBColumn *ctx;
    Data_Get_Struct(obj, rubyDuckDBColumn, ctx);

    rubyDuckDBResult *ctxresult;
    Data_Get_Struct(oDuckDBResult, rubyDuckDBResult, ctxresult);

    ctx->_type = duckdb_column_type(&(ctxresult->result), col);
    ctx->_name = duckdb_column_name(&(ctxresult->result), col);

    return obj;
}

# アクセスできるようにした
VALUE duckdb_column_name_value(VALUE oDuckDBColumn) {
    rubyDuckDBColumn *ctx;
    Data_Get_Struct(oDuckDBColumn, rubyDuckDBColumn, ctx);

    return rb_str_new2(ctx->_name);
}

あってる気しないけど、取り急ぎ動いた!

レビューもらって実装

[#168]Add DuckDB::Column · okadakk/ruby-duckdb@38502cc · GitHub

# duckdb.h を見る限り、カラム名を取得したいときは、duckdb_result と index があればいいので、それをクラス変数として定義する。
struct _rubyDuckDBColumn {
    VALUE result;
    idx_t col;
};

# Columnクラスを生成する。Column.new をしてる感じ。
# Class#new は Class#allocate でインスタンスを生成し、 Object#initialize で初期化を行っているので、ほぼ同じ。
VALUE create_column(VALUE oDuckDBResult, idx_t col) {
    VALUE obj;

    // allocateをして、cDuckDBColumnに必要なメモリを確保する。
    obj = allocate(cDuckDBColumn);
    rubyDuckDBColumn *ctx;
    Data_Get_Struct(obj, rubyDuckDBColumn, ctx);

    // 変数に値を代入する。rb_ivar_setを使うと、Rubyからでもアクセス可能。
    rb_ivar_set(obj, rb_intern("result"), oDuckDBResult);
    ctx->col = col;

    return obj;
}

# get_name を定義
VALUE duckdb_column_get_name(VALUE oDuckDBColumn) {
    rubyDuckDBColumn *ctx;
    Data_Get_Struct(oDuckDBColumn, rubyDuckDBColumn, ctx);

 # rb_ivar_setをしたら、rb_ivar_getをすると値を取得できる。
    VALUE result = rb_ivar_get(oDuckDBColumn, rb_intern("result"));
    rubyDuckDBResult *ctxresult;
    Data_Get_Struct(result, rubyDuckDBResult, ctxresult);

    return rb_str_new2(duckdb_column_name(ctxresult->result, ctx->col));
}

# get_typeを定義
VALUE duckdb_column_get_type(VALUE oDuckDBColumn) {
    rubyDuckDBColumn *ctx;
    Data_Get_Struct(oDuckDBColumn, rubyDuckDBColumn, ctx);

    VALUE result = rb_ivar_get(oDuckDBColumn, rb_intern("result"));
    rubyDuckDBResult *ctxresult;
    Data_Get_Struct(result, rubyDuckDBResult, ctxresult);
    duckdb_type type = duckdb_column_type(ctxresult->result, ctx->col);

    switch (type) {
    case DUCKDB_TYPE_BOOLEAN:
        return ID2SYM(rb_intern("boolean"));
    default:
        return ID2SYM(rb_intern("invalid"));
    }
}

結果

マージしてもらった! github.com

RubyGCについて教えてもらったこと

(理解間違えてたらごめんなさい)

RubyGCは、「mark&sweep」というアルゴリズムで動いている。

簡単にいうと、mark phazeとsweep phazeでそれぞれ処理をしていて、

mark phazeは、root objectから参照があるobjectにのみどんどんマークをつけていく。

sweep phazeは、マークがついていないobjectのメモリを解放していく。

という感じ。

今回クラスを作るときに使った Data_Wrap_Struct は、第二引数にmark時に行う処理、第三引数にsweep時に行う処理を登録できる。

macro Data_Wrap_Struct (Ruby 3.1 リファレンスマニュアル)

今回は、第三引数にメモリ解放する関数を設定したので、sweep時に、正しくメモリが解放される。 これがないと、CのAPIで作ったクラスは、メモリが解放されない?

# 変数に代入する際に二つの方法があるが、以下のように異なる。

# こっちだと、Rubyが勝手にメモリ解放とかを管理してくれる
rb_ivar_set(obj, rb_intern("result"), oDuckDBResult);

# こっちだと、手動でメモリ解放を正しく管理しないといけない。(ruby-duckdbではdeallocateでメモリ解放してる)
ctx->col = col;

感想

CのAPI実装を介して、Rubyのことをもっとよく知れた。 よかった!

OSSチャレンジPart6 (RubyのC API実装)

目的

学んだことを忘れないようにメモ!

間違えたことを書いている可能性あるので、注意してください!

あと、全部教えてもらいながらやったことを書いてるだけなので、自分の力ではないです。

やりたいこと

前回Part5で、作成したglibの実装を使って、MonthIntervalType等をred-arrowでも利用できるようになった。

しかし、arrowは大量のデータを使うので、一つ一つの値の変換処理を、Rubyでやっていたら遅いという問題がある。

なので、Cで変換するように他は実装されてるので、今回作ったMonthIntervalType等も同様にCで変換できるようにする。

やったこと

MonthIntervalType

ARROW-15749: [Ruby] Add support for #values of Month Interval Type by okadakk · Pull Request #12529 · apache/arrow · GitHub

たったこれだけ。

INT2NUMで、CのInt型から、Rubyで扱えるVALUE型(Rubyで扱うときはNumber型)に変換してる。

inline VALUE convert(const arrow::MonthIntervalArray& array,
                         const int64_t i) {
      return INT2NUM(array.Value(i));
}

DayTimeIntervalType

ARROW-15885 [Ruby] Add support for #values of DayTime Interval Type by okadakk · Pull Request #12593 · apache/arrow · GitHub

DayTimeIntervalのvalueは、daysとmillisecondsというメンバー変数を持つDayMillisecondというクラス?なので、そのまま渡せない。

今回は、RubyのhashObjectを作って(rb_hash_new)、そこに rb_hash_aset(hash[0] = hoge みたいにhashの特定のkeyに値を入れられる)を入れて値を入れている。

変数のdaysとか、millisecondsは、int32なので、VALUE型(Rubyで扱うときはNumber型)に変換。

    inline VALUE convert(const arrow::DayTimeIntervalArray& array,
                         const int64_t i) {
      auto value = rb_hash_new();
      auto arrow_value = array.Value(i);
      rb_hash_aset(value,
                   red_arrow::symbols::day,
                   INT2NUM(arrow_value.days));
      rb_hash_aset(value,
                   red_arrow::symbols::millisecond,
                   INT2NUM(arrow_value.milliseconds));
      return value;
    }

red_arrow::symbols::day は、以下のように定義してる。

rb_internとすると、整数ID型とやらにできる。

ID2SYMは整数ID型を受け取り、VALUE型(Rubyで扱うときはSymbol型)に変換してるから、ここでは、red_arrow::symbols::day は「:day」のことを指す。

red_arrow::symbols::day = ID2SYM(rb_intern("day"));

テストリファクタ

ARROW-15918 [Ruby] Support hash as argument of DayTimeIntervalArray.new by okadakk · Pull Request #12611 · apache/arrow · GitHub

こんな感じのクラスを追加すると、DayTimeIntervalArray.newした時に、好きな形式(今回はHash)でvalueを渡すことができた!

module Arrow
  class DayTimeIntervalArrayBuilder
    private
    def convert_to_arrow_value(value)
      if value.is_a?(Hash)
        Arrow::DayMillisecond.new(value[:day], value[:millisecond])
      else
        value
      end
    end
  end
end

感想

Ruby はすべてのデータがオブジェクトって聞いたことがあって、よく分かってなかったけど、今回で理解できた。

Cから受け取った値をRubyで扱うために、全てValue型にしないといけないというのを実感できて、本当に勉強になった。

この実装方法を教えてもらってから、Ruby gemのC実装のコードもほぼ読めるようになったし、書けるようにもなってきたから、本当に感謝!!

OSSチャレンジPart5 (glib実装)

目的

学んだことを忘れないようにメモ!

間違えたことを書いている可能性あるので、注意してください!

あと、全部教えてもらいながらやったことを書いてるだけなので、自分の力ではないです。

やりたいこと

RubyでArrowのMonth, Day, Nano Interval Typeを使えるようにする。

RubyC++のライブラリを使う方法

詳しくはこちら!

github.com

簡単に書くと、いくつか方法があって、RedArrowでは GObject Introspection を用いています。

C・C++で実装されたライブラリーの機能をRubyから使えるようにしたRubyのライブラリーのことを「バインディング」と呼びます。

と上のページに書いてあるように、今回はバインディングと呼ばれるRubyのライブラリーを作ります。

そのバインディングは、すでに作成されているので、今回はこちらを修正していきます。

arrow/c_glib/arrow-glib at master · apache/arrow · GitHub

作業した流れ

TemporalData型クラスを追加

cppの実装部分をRubyからの利用できるようにしたい。

この IntervalType は TemporalType を継承しているのだが、まだ TemporalType を使えるようになってなかったので、まず追加した。

ARROW-14935: [GLib] Add GArrowTemporalDataType by okadakk · Pull Request #11809 · apache/arrow · GitHub

この型は基底クラスで、特にメソッドを持っているわけではないので、定義だけ追加すればOK!

IntervalDataクラスを追加

次にIntervalData型クラスを追加する。

ARROW-15134: [GLib] Add GArrow{Month,DayTime,MonthDayNano}IntervalDataType by okadakk · Pull Request #11975 · apache/arrow · GitHub

cppでの定義はこちら

class ARROW_EXPORT IntervalType : public TemporalType, public ParametricType {
 public:
  enum type { MONTHS, DAY_TIME, MONTH_DAY_NANO };

  virtual type interval_type() const = 0;

 protected:
  explicit IntervalType(Type::type subtype) : TemporalType(subtype) {}
  std::string ComputeFingerprint() const override;
};

バインディングを作る必要があるメソッドを決める

まず、こちらのバインディングを作る際にどのメソッドを使えるようにするべきかを考える。

public:
  enum type { MONTHS, DAY_TIME, MONTH_DAY_NANO };

ここのenumは、MONTHS = 0, DAY_TIME = 1 みたいな定義をしてるだけなので、type.hに同じように追加すれば、OK!!

 protected:
  explicit IntervalType(Type::type subtype) : TemporalType(subtype) {}

ここは、ruby でいう new だが、protectedということは、外から使えないので今回は実装しなくてOK!

protected:
  std::string ComputeFingerprint() const override;

こちらも同じく外から使えないので、実装しなくてOKなのと、overrideしてるということは、継承先の方で既にバインディングを作成するべきなので、ここでは作らない。

public:
  virtual type interval_type() const = 0;

このメソッドだけは実装する必要がある。

メソッドのバインディングを作る

glibの世界とC++の世界に分けて考えるとわかりやすい。

まず、引数のGArrowIntervalDataTypeは、glibで定義した型 = Rubyから渡されたもの。

これを、まずC++の世界で利用できる形に変換する。

そして、C++で作られたメソッドを叩く。

最後に、C++のメソッドの戻り値をglib(=Ruby)の世界で利用できる形に変換する。

// GArrowIntervalType = cpp での arrow::IntervalType::type 相当
GArrowIntervalType
garrow_interval_data_type_get_interval_type(GArrowIntervalDataType *data_type) {
  // 最初の変換処理(glib -> C++)
  // GARROW_DATA_TYPE(data_type)) は、親クラスにcastしてる。
  //   - 実行時に、本当に子クラスなのかのvalidationとかもしてる
  //   - イメージはこんな感じ。(GArrowDataType*) data_type
  // garrow_data_type_get_raw で、arrow::BaseType 的な型にcastしてる。
  // std::static_pointer_cast<arrow::IntervalType> で、arrow::IntervalTypeに、castしてあげることで、C++で扱える形に変換完了!
  auto arrow_data_type =
    std::static_pointer_cast<arrow::IntervalType>(
      garrow_data_type_get_raw(GARROW_DATA_TYPE(data_type)));
  
  // C++の関数を呼ぶだけ。結果はC++で扱える形(arrow::IntervalType::type)の状態
  auto arrow_interval_type = arrow_data_type->interval_type();
  
  // 最後の変換処理(C++ -> glib)
  // arrow::IntervalType::type を GArrowIntervalTypeに変換する
  return std::static_cast<GArrowIntervalType>(arrow_interval_type);
}

子クラスを作る

ここで問題として、IntervalDataクラスは new できないので、テストができない。

そのため、テストできるように子クラスを追加して、そこに new メソッドを追加する。

GArrowIntervalMonthsDataType *
garrow_interval_months_data_type_new(void)
{
  auto arrow_data_type = arrow::month_interval();

  GArrowIntervalMonthsDataType *data_type =
    GARROW_INTERVAL_MONTHS_DATA_TYPE(g_object_new(GARROW_TYPE_INTERVAL_MONTHS_DATA_TYPE,
                                                  "data-type", &arrow_data_type,
                                                  NULL));
  return data_type;
}

テストを書く

compileして、installした後に、こんな感じに書いたら動いた

class TestIntervalMonthsDataType < Test::Unit::TestCase
  def test_type
    data_type = Arrow::IntervalMonthsDataType.new
    assert_equal(Arrow::Type::INTERVAL_MONTHS, data_type.id)
  end

  def test_interval_type
    data_type = Arrow::IntervalMonthsDataType.new
    assert_equal(Arrow::IntervalType::MONTHS, data_type.interval_type)
  end

  def test_name
    data_type = Arrow::IntervalMonthsDataType.new
    assert_equal("month_interval", data_type.name)
  end

  def test_to_s
    data_type = Arrow::IntervalMonthsDataType.new
    assert_equal("month_interval", data_type.to_s)
  end
end

ScalarClass, ArrayClassを作る

ARROW-15462: [GLib] Add GArrow{Month,DayTime,MonthDayNano}Interval{Scalar,Array,ArrayBuilder} by okadakk · Pull Request #12269 · apache/arrow · GitHub

書き方はルール的なのがあるので、他のコードや、下の説明を真似して書く。

github.com

ScalarClass

「new」,「value」を実装すればよい。

ScalarClassはC++の実装では、型定義が必要だから、Scalar型を定義する必要があるが、Rubyから利用されることはあんまりない。

Scalar型がIntとかBooleanなどそれぞれに定義することで、処理が抽象化できるので便利!

scalar.h に定義を書く

GARROW_AVAILABLE_IN_8_0
GArrowMonthIntervalScalar *
garrow_month_interval_scalar_new(gint32 value);
GARROW_AVAILABLE_IN_8_0
gint32
garrow_month_interval_scalar_get_value(GArrowMonthIntervalScalar *scalar);

scalar.c に実装を書く

GArrowMonthIntervalScalar *
garrow_month_interval_scalar_new(gint32 value)
{
  # 引数の値を、glibからC++で利用できる形に変換
  auto arrow_scalar =
    std::static_pointer_cast<arrow::Scalar>(
      std::make_shared<arrow::MonthIntervalScalar>(value));
  # C++のarrow::Scalarのtypeを見て、glibで利用できるクラスを生成
  return GARROW_MONTH_INTERVAL_SCALAR(garrow_scalar_new_raw(&arrow_scalar));
}
gint32
garrow_month_interval_scalar_get_value(GArrowMonthIntervalScalar *scalar)
{
  # glibからC++の形に変換
  const auto arrow_scalar =
    std::static_pointer_cast<arrow::MonthIntervalScalar>(
      garrow_scalar_get_raw(GARROW_SCALAR(scalar)));
  # C++のArrow実装のvalueを呼ぶ。MonthIntervalの値はただの数字なので、C++のint32が返ってくる。
  # gint32と互換性があるので変換しなくてOK!!
  return arrow_scalar->value; 
}

ArrayClass

同様に「new」,「get_value」,「get_values」を実装すればよい。

実装を一応抜粋するが、共通化がされてるので、他のところの真似をしただけでできた。

basic-array.h

GARROW_AVAILABLE_IN_8_0
GArrowMonthIntervalArray *
garrow_month_interval_array_new(gint64 length,
                                GArrowBuffer *data,
                                GArrowBuffer *null_bitmap,
                                gint64 n_nulls);
GARROW_AVAILABLE_IN_8_0
gint32
garrow_month_interval_array_get_value(GArrowMonthIntervalArray *array,
                                      gint64 i);
GARROW_AVAILABLE_IN_8_0
const gint32 *
garrow_month_interval_array_get_values(GArrowMonthIntervalArray *array,
                                       gint64 *length);

basic-array.c

GArrowMonthIntervalArray *
garrow_month_interval_array_new(gint64 length,
                                GArrowBuffer *data,
                                GArrowBuffer *null_bitmap,
                                gint64 n_nulls)
{
  auto array = garrow_primitive_array_new<arrow::MonthIntervalType>(length,
                                                                    data,
                                                                    null_bitmap,
                                                                    n_nulls);
  return GARROW_MONTH_INTERVAL_ARRAY(array);
}
gint32
garrow_month_interval_array_get_value(GArrowMonthIntervalArray *array,
                                      gint64 i)
{
  auto arrow_array = garrow_array_get_raw(GARROW_ARRAY(array));
  return static_cast<arrow::MonthIntervalArray *>(arrow_array.get())->Value(i);
}
const gint32 *
garrow_month_interval_array_get_values(GArrowMonthIntervalArray *array,
                                       gint64 *length)
{
  auto arrow_array = garrow_array_get_raw(GARROW_ARRAY(array));
  return garrow_array_get_values_raw<arrow::MonthIntervalType>(
    arrow_array, length);
}

ArrayBuilderClass

同様に「new」,「append_value」,「append_values」を実装すればよい。

実装を一応抜粋するが、こちらも共通化がされてるので、他のところの真似をしただけでできた。

ArrayBuilderはなんのためにあるのか質問したところ、以下と教えてもらった。

Arrayにappendしたい時に、メモリ上どう配置するかを実装者が考えないといけない。その時に、ArrayBuilderを使うとarrowのメモリ配置ルールに沿って、データをメモリに割り当ててくれる。

また、C++レベルでは、arrowのArray.new()が受け取るのは、binrayのデータであるので、stringの配列とかを渡してarrowのArrayを作ることはできない。

なので、stringの配列から、arrowのArrayを作りたいときは、ArrayBuilderを用いる必要がある。

array-builder.h

GARROW_AVAILABLE_IN_8_0
GArrowMonthIntervalArrayBuilder *
garrow_month_interval_array_builder_new(void);

GARROW_AVAILABLE_IN_8_0
gboolean
garrow_month_interval_array_builder_append_value(
  GArrowMonthIntervalArrayBuilder *builder,
  gint32 value,
  GError **error);
GARROW_AVAILABLE_IN_8_0
gboolean
garrow_month_interval_array_builder_append_values(
  GArrowMonthIntervalArrayBuilder *builder,
  const gint32 *values,
  gint64 values_length,
  const gboolean *is_valids,
  gint64 is_valids_length,
  GError **error);

array-builder.cpp

GArrowMonthIntervalArrayBuilder *
garrow_month_interval_array_builder_new(void)
{
  auto builder = garrow_array_builder_new(arrow::month_interval(),
                                          NULL,
                                          "[month-interval-array-builder][new]");
  return GARROW_MONTH_INTERVAL_ARRAY_BUILDER(builder);
}
gboolean
garrow_month_interval_array_builder_append_value(
  GArrowMonthIntervalArrayBuilder *builder,
  gint32 value,
  GError **error)
{
  return garrow_array_builder_append_value<arrow::MonthIntervalBuilder *>
    (GARROW_ARRAY_BUILDER(builder),
     value,
     error,
     "[month-interval-array-builder][append-value]");
}
gboolean
garrow_month_interval_array_builder_append_values(
  GArrowMonthIntervalArrayBuilder *builder,
  const gint32 *values,
  gint64 values_length,
  const gboolean *is_valids,
  gint64 is_valids_length,
  GError **error)
{
  return garrow_array_builder_append_values<arrow::MonthIntervalBuilder *>
    (GARROW_ARRAY_BUILDER(builder),
     values,
     values_length,
     is_valids,
     is_valids_length,
     error,
     "[month-interval-array-builder][append-values]");
}

DayTimeInterval, MonthDayNanoIntervalの実装

MonthIntervalは月の差分はただの数値で表現される。(例えば、3月と5月だったら差分は「2」)

だが、DayTimeInterval や MonthDayNanoInterval は、違ってて、以下のようにCのstructが返ってくる。

struct DayMilliseconds {
    int32_t days = 0;
    int32_t milliseconds = 0;
}

だから、難しかった...

まず、Cのstruct相当のものをglibで実装する。

arrow/interval.cpp at e402be253a5e5c99790f03783604bc1e9a139a88 · okadakk/arrow · GitHub

それと、glibで作ったそれを、Cのstructに相互変換するメソッドを実装した。

GArrowDayMillisecond *
garrow_day_millisecond_new_raw(
  arrow::DayTimeIntervalType::DayMilliseconds *arrow_day_millisecond)
{
  auto day_millisecond =
    g_object_new(garrow_day_millisecond_get_type(),
                 "day", arrow_day_millisecond->days,
                 "millisecond", arrow_day_millisecond->milliseconds,
                 NULL);
  return GARROW_DAY_MILLISECOND(day_millisecond);
}

arrow::DayTimeIntervalType::DayMilliseconds *
garrow_day_millisecond_get_raw(GArrowDayMillisecond *day_millisecond)
{
  auto priv = GARROW_DAY_MILLISECOND_GET_PRIVATE(day_millisecond);
  return &priv->day_millisecond;
}

あとは、MonthIntervalと同じように実装。(詰まったけど教えてもらってなんとか完成した)

その他学んだこと

  • GlistはLinkedList, CのListとは構造が違う。
    • 素数があらかじめわかる場合はListを使った方がいいが、arrow-glibは、Glistを使ってるところが多いので、基本Glistを使う。
  • glibは、コメントによって、所有権をわたす(= 呼び出した人がメモリ解放する)かどうかを記載する。  - このコメントを見て、glibがいい感じにメモリ解放とかしてくれる。コメント大事!!

感想

めっちゃ難しかった...

基本的には、glib(Cで実装されてる)のコードを、C++(arrow本体はC++で実装されてる)に変換するコードを書くだけだから、他のところを真似すれば、そんなに難しくないし、理解もしやすかった。

ただ、真似できないところになるとどうやればいいか分からなかった...

まだ全然理解できてる気はしないけど、雰囲気は掴めたので今後も触ってみたい!