BigQuery Emulatorとクエリパラメータ

BigQueryのクエリ(Google SQL構文)にはParameterized Queryという機能があり、クエリに任意の式を埋め込むことができます。

cloud.google.com

上記のページより引用すると以下の@gender@statesのように、@識別子の形式でクエリを書き、実行時にパラメータを渡します。これはNamed Parameter(名前付きパラメータ)と呼ばれます。

   SELECT
     name,
     SUM(number) AS count
   FROM
     `bigquery-public-data.usa_names.usa_1910_2013`
   WHERE
     gender = @gender
     AND state IN UNNEST(@states)
   GROUP BY
     name
   ORDER BY
     count DESC
   LIMIT
     10;

BigQuery Emulatorもこの機能に対応しており、ほとんどのケースでうまく動作しますが、たまにうまくいかないケースがあります。

うまくいかないケース

以下なぜうまくいかないのか?という話をしますが、BigQuery Emulatorとgo-zetasqlite, go-zetasql, zetasql の関係等々については前提として省略します。

作者であるgoccyさんのスライドがわかりやすいでしょう。

BigQueryエミュレータの作り方 - Speaker Deck

パターン1: zetasqlの解析でエラーになる

Error processing job with UNNEST and query parameters · Issue #234 · goccy/bigquery-emulator · GitHub

このissueで報告されている問題のパターンで、FROMにUNNEST(@param)を書くとエラーになるというものです。

これは確かにエラーになります。

パターン2: zetasqlは解析できるけどgo-zetasqliteでエラーになる

例えばSELECT @paramで@paramが配列などです。

これもエラーになります。

なぜなの?

これはクエリ実行時に渡されたパラメータの型と、クエリを解析した結果のパラメータの型が食い違ってしまうことがあるのが原因です。

食い違ってしまうことがあるのは、bigquery-emulatorとその内部で使っているgo-zetasqliteのパラメータの扱いの違いによります。

bigquery-emulatorでは、一応クエリ実行時に渡された型を使っていますが、go-zetasqliteはパラメータの型を扱いません。*1

じゃあなぜちゃんと動いてるの?

go-zetasqliteは多くの場合ではパラメータの型を使わなくてもうまく動きます。

go-zetasqliteの中で使われていて、BigQueryのSQLパーサであるzetasqlはAllowUndeclaredParametersという設定を用意しています。(go-zetasqliteではこの設定を有効にしています。)

この設定を有効にすると宣言されていない(解析の際にパラメータの型が渡されていない)パラメータは、解析の際にはINT64として扱われてます。

型を無視してINT64として扱っているので上述の「zetasqlの解析でエラーになる」の通り動かないケースもありますが、型のチェックがあまり厳しくないコンテキストや関数では解析時にはINT64として扱うけど、解析結果ではそのコンテキストや関数が期待する型をそのパラメータの型として構文木を作ってくれます。

例えばパラメータがWHERE句で WHERE x = @x という形で使われていた場合、@xxと同じ型であろうと推測できます。

同様に、LOWER(@x) という形で使われていた場合、 LOWER関数の引数は文字列型なので、文字列であろうと推測できます。

このような場合、

  • 解析した結果期待される型とクエリ実行時に渡した型が一致していて
    • 厳密には渡す型はリテラルであるか、構造体であるか、配列であるかだけ合ってればいいです
    • つまり int64 と string と date は何を渡しても動作は同じ
  • 渡した値が期待される型にキャストできる

のであれば問題なく動作します。

echo 'SELECT LOWER(@x)' | bq --project_id test --dataset_id test --api http://localhost:9050 query --parameter 'x:string:"FOO"'
+-------+
| $col1 |
+-------+
| "foo" |
+-------+

なんでうまくいかないの?

パターン1: zetasqlの解析でエラーになる

このパターンはzetasqlの中でそのコンテキストにおいてパラメータが正しい型であるか検証されているが、クエリ実行時に渡した型ではなくINT64として扱われているので期待する型でないケースです。

例として FROM UNNEST(@param) がありましたが、 FROMUNNEST が使われている場合、zetasqlは引数が配列型であるかどうかをチェックし、配列型でない場合は例外を投げてしまいます。

他にも、ARRAY_CONCAT は引数の型チェックをしますし、その他多くの関数で型がチェックされている場合はエラーになります。

ちなみにチェックの緩いやつはうまくいくうえに、解析した結果ちゃんと型が付きます。例えば DATE_ADD の場合

echo -n 'select DATE_ADD(@x, INTERVAL 1 MONTH)' | bq --project_id test --dataset_id test --api http://localhost:9050 query --parameter 'x:int64:2024-01-01'

これを投げると、zetasqlは↓を返してきて

QueryStmt
+-output_column_list=
| +-$query.$col1#1 AS `$col1` [DATE]
+-query=
  +-ProjectScan
    +-parse_location=0-37
    +-column_list=[$query.$col1#1]
    +-expr_list=
    | +-$col1#1 :=
    |   +-FunctionCall(ZetaSQL:date_add(DATE, INT64, ENUM<zetasql.functions.DateTimestampPart>) -> DATE)
    |     +-parse_location=7-37
    |     +-Parameter(parse_location=16-18, type=DATE, name="x")
    |     +-Literal(parse_location=29-30, type=INT64, value=1)
    |     +-Literal(type=ENUM<zetasql.functions.DateTimestampPart>, value=MONTH)
    +-input_scan=
      +-SingleRowScan

クエリ結果も期待通りの結果になります。

echo -n 'select DATE_ADD(@x, INTERVAL 1 MONTH)' | bq --project_id test --dataset_id test --api http://localhost:9050 query --parameter 'x:int64:2024-01-01'
+------------+
|   $col1    |
+------------+
| 2024-02-01 |
+------------+

パターン2: zetasqlは解析できるけどgo-zetasqliteでエラーになる

これはbigquery-emulatorがgo-zetasqliteに渡す値の型と、zetasqlの解析の結果期待される型が食い違っているのでキャストに失敗するケースです。

例としては SELECT @param を出しましたが、これだけ見ても型がわかりませんね。

ここに配列を渡した場合、zetasqlがクエリを解析する際には(ほんとは違うけど)INT64として素通しします。

しかし、正しく配列型であることを指定してパラメータを渡した場合、bigquery-emulatorはそこが配列型であることを知っているのでgo-zetasqliteに@paramを配列として渡します。

go-zetasqliteはクエリを実行する際に、当然解析したクエリが期待する型に合わせてパラメータをキャストする必要があります。

その際、配列をINT64にキャストしようとしてしまってエラーになるわけです。

一体どうすれば

そもそも SELECT @param とか SELECT * FROM UNNEST(@param) とか、本番で使うことあるんかい、と突っ込まれそうですがしばしばあります。(まぁ大抵回避可能だと思います)

どうしても使う場合、本物のBigQueryで使うときには無駄になっちゃいますが、一旦 CAST すれば問題を回避することができます。

echo 'select * from UNNEST(@x)' | bq --project_id test --dataset_id test --api http://localhost:9050 query --parameter 'x:array<string>:["foo","bar","baz"]'
BigQuery error in query operation: Error processing job 'test:bqjob_r6ebd5d509bffc7d9_0000018d6031a91d_1': failed to analyze: INVALID_ARGUMENT: Values referenced in UNNEST must be arrays.
UNNEST contains expression of type INT64 [at 1:22]
 ❯ echo 'select * from UNNEST(CAST(@x as array<string>))' | bq --project_id test --dataset_id test --api http://localhost:9050 query --parameter 'x:array<string>:["foo","bar","baz"]'
+----------+
| $unnest1 |
+----------+
| foo      |
| bar      |
| baz      |
+----------+

*1:golangのdatabase/sqlパッケージのインターフェースを実装しているため、そもそもパラメータの型を渡すことが難しい構造のよう。NamedValueCheckerを使ってなんとか渡せるようにすることはできるが、パラメータの型を必要としている箇所はdriverの内部なのでそれで渡したところで必要なところに届かない。

Log Structured Storageを重ねて使うと起きる問題とLLAMAについて調べたことまとめ

Database Internalsを読んでて、7章の最後 Log Stacking のところで疑問が多くていろいろ調べていたなかのひとつ。

HSEとは何か - Speaker Deck

LLAMAの話ではないけど同じような話としてこの↑資料が良かったのでこれを見よう。

Log Structured Storageを重ねて使うと起こる問題

ファイルシステムSSDもLog Structuringを利用していて、昨今Log Structured Storageを利用するアプリケーションも増えてきていて、たくさんの階層でログが使われている。そうするといろいろと不都合があって、その不都合については本でも引用されているこれ↓に書いてあるので

https://www.usenix.org/sites/default/files/conference/protected-files/inflow14_slides_yang.pdf

https://www.usenix.org/system/files/conference/inflow14/inflow14-yang.pdf

ここを見ようという感じだし、本にも書いてあるが、以下のような問題がある

  • メタデータが増えて書き込みが増える、メモリの使用量も増える
  • 上の階層で複数の書き込みストリームがあると、上の階層ではそれらがシーケンシャルでも下の階層にはそれらが交互に書き込まれてしまってフラグメンテーションすることがある

    • 複数の書き込みストリームというのは、例えばデータベースならデータとログの書き込み。または並列に書き込む場合はそれも複数の書き込みストリームということになる。
      Don’t stack your Log on my Log p14より
  • ストリームが1つの場合でも、上の階層と下の階層でセグメントのサイズが違う場合、上の階層のgcが下の階層の複数のセグメントを無効化する可能性がある。そうすると、下の階層でそのセグメントを回収するためには2つのセグメントをgcしないといけなくなる

    Don’t stack your Log on my Log p15より

  • 上の階層と下の階層で別々にgcするので、まず下の階層でgcしてデータを移動してから、上の階層でgcして、更に下の階層でデータを無効化する、みたいなムダが発生しうる

これはそれぞれの階層が独立してログを書き込み、サイズを管理し、別々にコンパクションをするから問題が起きてしまう。これを解消するために、ファイルシステムSSDFTLも統合して管理しましょうというアプローチがある。多分LLAMAはこのアプローチの1つ。

LLAMAとは

Microsoftの「LLAMA: A Cache/Storage Subsystemfor Modern Hardware」というのが(ほぼ?)唯一の詳しい資料。

Microsoftが発表しているキャッシュとストレージを管理するシステム。キャッシュレイヤーとストレージレイヤーを持ち、2つのレイヤーで同一のマッピングテーブルを使ってメモリ内のキャッシュと二次記憶(SSD)のデータを管理する。マッピングテーブルというのはページのIDと物理アドレスの対応を管理するもの。物理アドレスはRAM上だったりFlash上だったりする。

LLAMA: A Cache/Storage Subsystemfor Modern Hardwareより

LLAMAはカバーする範囲がかなり広いけど、アプリから扱うところで考えるとキャッシュレイヤーは従来でいえばページキャッシュで、ストレージレイヤーはファイルシステムとかになるんだろうか。

特徴は名前が示すとおり同期が必要な箇所ではCASを使い、一切ラッチを使わないことと、Log Structuringの仕組みを利用しているということ。

この話は同じくMicrosoftが発表しているBw-Treeと同じ。ラッチフリーなLog Structuringの強みは、

  • ラッチを使わないことでマルチコアでスケールする
  • in place更新をしないで差分の追記のみにすることでキャッシュの無効化を防げる
  • ディスクへの書き込みをまとまったシーケンシャルライトにできる

というのを利用してB-Treeを改善したのがBw-Treeで、その考え方をキャッシュとストレージの管理に利用したのがこのLLAMAなんだと思われる。

なぜLLAMAが出てきたのか

上に書いたように(恐らく)LLAMAは従来のファイルシステムSSDFlash Translation Layerなどを含んでいてキャッシュとストレージをまとめて管理するので、ログが階層化することで発生する問題を避けられる。

物理層のページの位置をホストが管理していて、それを操作するAPIをアプリケーションに提供するので、そのAPIを使ってアプリケーションを実装していれば書き込みのinterleaveやらセグメンテーションのサイズが違うとかでフラグメンテーションが起きることがない。

LLAMAを使って構築したBw-Treeの場合、Flash Storageに書き出す前に差分をマージしてまとめてから書き込んでスペースを減らしたり、打ち合う操作は消してから書き込んで書き込む量を減らすこともできる。

蛇足

最初「LLAMA: A Cache/Storage Subsystemfor Modern Hardware」を読んでると、良さはlatch freeとlog structuredであることでマルチコアを活かせることとメモリのinvalidationも減らせるのでいいんだということのように思えたので、なんでDatabase InternalsのLog StackingのところでLLAMAが出てきたのかよくわからなかった。ログの階層化の問題を回避するためにFTLをホストレベルで扱うとかはもはや普通のことだから特に書くまでもないということなのか、Cache LayerとStorage Layerで同じマッピングテーブルを使うよ、マッピングテーブルってのはPage IdとPhysical Address(など)との対応を管理するものなんだよ、というところから自明ってことなのかな?

Open-Channel SSDについて調べててなんとなくそういうことかなと理解してきたのと、実験で使ったと書いてあるSSDはFusion-IOで、Fusion-IOはOpen-Channel SSDじゃないだろう?じゃあどうやってFTLをホスト側で??と思ってFusion-IOで調べてたら資料が出てきた。

Optimizing I/O Operations via the Flash Translation Layer

資料の図を見るとホストからFTLを触れるようになっているような気がするし、 "Client software direct access to flash memory" って書いてある

RailsではなくRocketでHotwire(Turbo)する その1

HotwireというかTurboは単なるJavaScriptのライブラリなので、Railsじゃないと使えないということはない。Railsでturbo-rails gemを使うととても便利に使えることは間違いないけど。

ということでRust製のウェブアプリケーションフレームワークのRocketでアプリを書くのを試すついでに、Ralis以外でHotwireを導入することを試してみた。

成果物と導入、Turboのおかげでよくなっているポイントを1つ書く

成果物

できたのはこれ。本家のデモと同じくチャット

github.com

こんな感じに動く。左と右で2つのブラウザでアプリを開いているところ

導入

RocketやRails以外のフレームワークでTurboを導入する場合は、webpackなど何かしらJavaSciprtのbunderを使って、通常のnpm packageを使うのと同じ方法で使える。

Turbo DriveとTurbo Framesに関してはインストールしてTurboをjsで読み込んでおくだけで有効になるのでなんの手間もない。

Turboが活きているポイント:フォーム周りの動的な振る舞い

部屋を追加するところや、部屋の名前を変更するところで、ページ遷移せずにフォームが追加されたりプレーンテキストだった箇所がフォームに変更されたりしている。これはTurbo Framesを使っている。Turbo Framesを使うとページを複数のフレームに分割することができて、分割したフレーム内でページ遷移が発生すると、ページ全体を書き換えず対応するフレームの箇所だけを遷移先のページのものに置き換えてくれる。

f:id:totem_3:20210128150303p:plain

この+ボタンは/rooms/newというURLの部屋追加画面への単なるリンクで、Turboを読み込むことをやめるとただそのページに遷移するだけになる。遷移先の/rooms/newのページは右の画面で、フォームだけがある雑なページが用意されている。

それぞれのページで赤で囲んだ箇所が turbo-frame というタグで囲まれたひとつのフレームになっていて、同じidを付けているので、この中でページ遷移をするとページ全体が遷移するのではなくこのフレームだけが書き換わる。

部屋の名前を変更する箇所も同様で、/rooms/editという雑な編集ページが用意されていて、ペンマークのボタンは編集ページへのただのリンクになっている。

トップのチャット画面のタイトル部分と、編集ページのフォーム部分が Turbo Frame になっているので、ペンボタンを押して遷移するとタイトル部分がフォームに書き換わり、submitするとまたタイトルになる。

このように単純な複数の静的なページと、通常のページ遷移だけで動的なページが作れるのはいい感じ(かもしれない)

まとめ

  • Turbo(の一部)の導入はRailsじゃなくてもとても簡単
  • サーバサイドでビューをレンダリングしていて、静的なページをちょっとリッチに(?)したいときに便利かもしれない
  • その2に続くかもしれない

HotwireのTurbo Frame, Turbo Streamでテーブル ( table > tbody ) に行 ( tr )を追加するにはどうしたらよいか

身も蓋もない話ですが、できないのでテーブルのようなDOMを他のエレメントで作りましょう。

tbody 要素のドキュメント に書いてあるように、tbody要素の配下には0個以上のtr要素しか置けない。無理やり置いても外に出されてしまう。

そんなこと言ったらul要素だってli,script,templateしか許されないと書いてあるけど、こっちは普通に書けるじゃないか!という話はあってそれは本当にそうなんですが、tableに関してはブラウザが厳しい様子

display: table, display: table-row, display: table-cell などを使えばそれっぽく作ることができる。

Tailwind CSS を使えばこんな感じで。

  <div class="table border-collapse">
    <div class="table-header-group">
      <div class="table-row text-center font-bold">
        <div class="table-cell border">A</div>
        <div class="table-cell border">B</div>
        <div class="table-cell border">C</div>
      </div>
    </div>
    <div class="table-row-group">
      <div class="table-row">
        <div class="table-cell border">foo</div>
        <div class="table-cell border">bar</div>
        <div class="table-cell border">baz</div>
      </div>
      <div class="table-row">
        <div class="table-cell border">hoge</div>
        <div class="table-cell border">fuga</div>
        <div class="table-cell border">piyo</div>
      </div>
    </div>
  </div>

外に出されてしまう例と、TailwindCSS+div+display: table(-xx) で作るtableと、ulの下にはdivが置けるの例は以下に

https://codepen.io/totem3/details/eYdqKYK

f:id:totem_3:20210127104756p:plain

RocketでカスタムContentTypeを作りレスポンスヘッダに設定する

Responses - Rocket Programming Guide

ドキュメントの Responses のページの Rocket Responders にある通り、 Content という Responder が ContentType を書き換えるために使える。

テンプレートをレンダリングして返す場合は Content<Template> を返すように定義して、下記のようにする(実際にはエラー処理するので Result を使うだろう)

fn custom() -> Content<Template> {
    let context = ...;
    Content(CustomContentType, Template::render("template_name", &context))
}

Contentの第一引数に渡す ContentType はHTMLとかJSONとか主要なContentTypeは定義されているが、定義されていないようなものを使う場合自分で作る。

使い回すし const で定義するとなると多分こんな感じになるんだけど、privateという名前のmodule以下の構造体使わないといけないので微妙。

use rocket::http::{ContentType, MediaType};
use rocket::http::private::{Source, MediaParams, Indexed};
use std::borrow::Cow;

pub const TURBO_STREAM_CONTENT_TYPE: ContentType = ContentType(MediaType {
    source: Source::None,
    top: Indexed::Concrete(Cow::Borrowed("text")),
    sub: Indexed::Concrete(Cow::Borrowed("vnd.turbo-stream.html")),
    params: MediaParams::Static(&[])
});

公開されているコンストラクタを使うと普通の conststatic にはできないので、 lazy_static! とかを使って普通に用意されているメソッドを使って作るのがいいのかなー?

fwrite(): send of xx bytes failed with errno=32 Broken pipe のエラーはkeepaliveが切れちゃうせいだった

XML RPC でサーバとやり取りしている PHP のバッチで、fwrite(): send of xx bytes failed with errno=32 Broken pipe のエラーが起きて原因がわからず困ってたことがあった。

そのバッチでは XML RPC のクライアントを最初に作って、それをずっと使いまわしていた。

その場合サーバとの接続はkeepaliveで維持される。

されるんだけど、あるリクエストを投げてから次のリクエストを投げるまでにとても時間がかかっているところがあった。(数分とか?)

エラーはそこで起きていた。

tcpdumpを眺めていると最後に通信してから90秒くらいたったところでRSTが来て通信が切れてた。(そのくらいでkeepaliveのtimeoutを迎えて切断されていた様子)

後続の処理は何も知らずにそこに書き込もうとしているのでこのエラーが起きる。

そんなに大量に接続するわけじゃないのでクライアントを作り直せばええやん、と思っていたのだけどこのときは真面目で優秀な若者が数分かかっていた処理を数秒に高速化して問題を解決した。

同じ原因でこのエラーにあったら、無駄な処理で時間がかかっているなら速くして接続が切れる前に通信を続けられればいいし、重い処理でどうしても時間がかかるならクライアントを分けるか、切れてたら接続し直すなどすれば解決できるはず。

Windows サービス関連のメモ

タスクスケジューラ

起動方法

起動時に実行したいなどイベントドリブンなタスクはタスクスケジューラから登録する(多分

タスクスケジューラはtaskschd.mscなので、ファイル名を指定して実行する

powershellスクリプトをタスクとして登録する

.batはわからないのでps1で書きたい(かけるものなら)が、タスクスケジューラの「プログラムの開始」で詳細にそのままps1のファイルを登録したら(デフォルトでは)メモ帳でps1ファイルが開くだけで実行されるわけではない。実行するには、詳細には「powershell」を指定し、引数の -File で実行したいファイルを指定する。

f:id:totem_3:20200509155518p:plain:w300

サービス

動かし続けたいものはサービスとして登録する(多分

これはタスクマネージャのサービスのところから起動できる