Linuxbrew(Homebrew) で入れた docker-compose で ModuleNotFoundError: No module named 'invoke' と言われたときにやること

環境

まあバージョンはあまり関係ないと思いますが。

  • Fedora 31 (WSL1)
  • Homebrew 2.5.2
  • Python 3.8.5
  • docker-compose 1.27.3

問題発生

Homebrew、Macは手元にないので Linuxbrew ですけど、で入れた docker-compose で次のエラーがでました。

ModuleNotFoundError: No module named 'invoke'

これは ~/.ssh/config の Match で exec が使用されていると発生します。

原因

paramiko でいくつかの依存パッケージがオプショナルになっており Match exec を使うためには invoke が必要なためです。

Linuxbrew で pyinvoke を入れれば大丈夫なのかと思ったのですが、ダメでした。

brew install pyinvoke

Python のことはよくわからないですが、それぞれ別の virtualenv だから、とかなのでしょうか。たぶん。

解決

docker-compose をインストールしたときに依存で入った python3 の pip3 で invoke をインストールすれば大丈夫です。

念のため python3 や pip3 が Linuxbrew のものになっているか確認します。

which python3 pip3

もしこれが /usr/bin/python3/usr/bin/pip3 になるなら invoke のインストールための pip コマンドは絶対パスで指定すると良いでしょう。

/home/linuxbrew/.linuxbrew/bin/pip3 install invoke

さいごに

~/.ssh/config で Match の exec とか使う人あまりいないのでしょうか。。。ちなみに Github の Releases からバイナリをダウンロードしたときは発生しません。これは pyinstaller というものでワンバイナリになっているようなのですが、何が違うんでしょう? 謎。

NodeJS とかでファイル更新を監視してプロセスを再起動しつつ Browsersync でリロード

NodeJS や Golang で Web アプリを作るとき、サーバサイドのコードを変更したときはプロセスの再起動が必要です。毎回手でやるのはさすがに煩雑なのでファイルの更新を監視して自動的に再起動してくれる系のツールがいろいろあります。

NodeJS なら下記などがよく使われるようです。

nodemon は実行コマンドを node から変更できるので NodeJS 以外でも使おうと思えば使えます。

node-dev は require() をフックすることで必要なファイルだけを監視、みたいな動作になるようです。なので監視対象のディレクトリやファイルの設定などしなくてもうまく動作します。その仕組的に NodeJS 以外では使えません。

ts-node-dev は node-dev の TypeScript 版です。TypeScript のコンパイラプロセスが毎回起動しなくて良いためリスタートが速いらしいです。

Golang なら oxequa/realize が鉄板なのでしょうか? ファイルの更新を監視して go run をリスタートする以外にもいろいろ出来るようです。

うーん? Go Modules に対応していないので cosmtrek/air のほうが良いの? 単にファイルの更新を監視してプロセスを再起動するだけではないんですかね。。。

watchexec

この種のツール、NodeJS や Golang に特化することで便利になっているところもあるようなのですが、基本的にはファイルの更新を監視してプロセスをリスタートするだけのものなので、それならもっと汎用的なものを使ってもよいのでは、と思います。

普段ファイルの更新を監視してなんやかんやするときに watchexec を使っているのですが -r オプションでプロセスのリスタートもできます。使い慣れているのでこれを使うことにします。

watchexec 自体は Homebrew で簡単にインストールできます。

brew install watchexec

次のようにすると src/ ディレクトリの *.ts ファイルを監視して、更新があると ts-node コマンドをリスタートしてくれます。

watchexec -r -w src/ -f '*.ts' -- env PORT=9876 ts-node -T src/app.ts

ファイル監視からのプロセスのリスタートはこれだけで十分です。

browser-sync

次に、サーバが HTML を返す系の Web アプリの場合、見た目の確認のためにいわゆる livereload も出来ると便利です。livereload にはいつも Browsersync を使ってます。便利です。

browser-sync start --port 3000 -p localhost:9876 --open -w -f '**/*.ts' -f '**/*.html' -f '**/*.css'

前述の watchexec と同時に実行すればブラウザで http://localhost:3000 を開けば Browsersync 経由で localhost:9876 でリッスンしている NodeJS のアプリにアクセスできます。

そして html や css を更新すると Browsersync でブラウザのリロード、サーバサイドのコードを更新したときは NodeJS のプロセスをリスタートした上で Browsersync でブラウザがリロードされ・・

ませんでした。サーバサイドのコードを更新したとき、NodeJS のプロセスが再起動しつつ Browsersync がブラウザで表示しているページをリロードするわけですが、NodeJS のプロセスが再起動してポートのリッスンが開始するより、ブラウザがリロードされる方が速いため、リロード後に Browsersync がプロキシ先のポートにアクセスできなくてエラーになります。

Makefile

試行錯誤のうえで出来上がったのがこれ。NodeJS のプロセスがリスタートするとき、NodeJS によってポートがリッスンされるのを待ってから Browsersync でリロードします。

APP_PORT = 9876
BS_PORT = 3000

.PHONY: all
all:
    $(MAKE) -j watch bs

.PHONY: app
app:
    env PORT="$(APP_PORT)" ts-node -T src/app.ts

.PHONY: watch
watch:
    watchexec -r -w src/ -f '*.ts' -- $(MAKE) -j app reload

.PHONY: bs
bs:
    browser-sync start --port "$(BS_PORT)" -p localhost:"$(APP_PORT)" --open -w \
        -f '**/*.html' \
        -f '**/*.css'

.PHONY: reload
reload:
    @while ! nc -z localhost "$(APP_PORT)"; do sleep 1; done
    browser-sync reload

Makefile を使っていますがなにかをメイクするわけではなくタスクランナーとしてだけ使っています。タスクを直列に実行したり並列に実行したりの制御が Makefile なら簡単なので。。。

watchexec からは $(MAKE) -j app reload で、アプリを開始するタスクと、ページをリロードするタスクを同時に実行します。リロードのタスクはアプリのポートがリッスンを待ってから browser-sync reload します。make-j はタスクの並列数を指定するオプションです。値を省略しているので無制限になります。

また、これら2つを同時に実行するためにデフォルトのタスクで $(MAKE) -j watch bs としています。

リッスンを開始したログをトリガにする版

試行錯誤中にできた、アプリが標準出力に出すログをトリガにする版(↑の方がスマートです)。

まず、アプリでリッスンを開始したときに適当なログを出力するようにしておきます。

const port = parseInt(process.env.PORT || '9876');
app.listen(port, () => {
    console.log(`Listening on :${port}`);
});

そしてこのメッセージが標準出力に流れてきたのをトリガーに browser-sync reload を実行します。

.PHONY: watch
watch:
    watchexec -r -w src/ -f '*.ts' -- $(MAKE) app \
        | tee /dev/stderr \
        | grep --line-buffered '^Listening on :' \
        | xargs -i browser-sync reload

watchexec で実行したアプリの標準出力を tee/dev/stderr に流しています。これはなくても動きますが、その場合アプリの標準出力の内容が失われます。

次に grep で標準出力にポートがリッスンしたときのメッセージが流れるのを待ちます。--line-buffered を付けて後段のパイプに行ごとにフラッシュされるようにします。

最後に xargs で標準入力になにか来るたびに browser-sync reload を実行します。-i を付けてコマンドにに余計な引数が付与されないようにするとともに、標準入力から1行読むたびにコマンドが実行されるようにします。

npm だけで実現する

↑の方法は自分ひとりで使う分には良いですが、チームで使うのであれば NodeJS ならやっぱり npm だけで実行できたほうが良いでしょうか。

npm でも npm-run-all で並列実行は出来ます。ポートのリッスンを待つのは wait-on で出来そうです。watchexecnodemon で問題ありません。node-devts-node-dev は実行コマンドを変更できないのでダメです。

次のようになりました。

{
"scripts": {
  "dev": "npm-run-all -p -r bs watch",
  "app": "ts-node -T src/app.ts",
  "watch": "nodemon -w src/ -e ts -x npm-run-all -- -p app wait-reload",
  "bs": "browser-sync start --port 3000 -p localhost:9876 --open -w -f \"**/*.ejs\" -f \"**/*.css\"",
  "wait-reload": "npm-run-all -s wait reload",
  "wait": "wait-on -v tcp:9876",
  "reload": "browser-sync reload"
}

npm run dev で開始できます。

npm run dev

さいごに

ググると gulp とかで同じようなことをやっている例がでてきました。きめ細かにやるならその方が良いのかもしれませんが、自分専用なら雑にありもののツールを組み合わせてこういうのもアリじゃないでしょうか。

VSCode で PHPUnit を実行する拡張を使ってみた

VSCode の Marketplace で PHPUnit で検索して出てきた中からダウンロード数が多い 3 つの拡張を使ってみました。

なお、普段は↓のようなめんどくさい環境でコードを書いています。

  • Windows で PhpStorm を実行している
  • PHP は WSL 上の docker-compse コマンドを使って Docker 環境で実行している
  • direnv で環境変数をいろいろ設定している

ので同様に VSCode も Windows 上から WSL 上で direnv を適用しつつ docker-compse を使いたいです。

emallin.phpunit

デフォで vendor/bin/phpunit とか vendor/phpunit/phpunit/phpunit とか phpunit*.phar とかを再帰的に検索して php コマンドで実行してくれます。vendor/bin/phpunit 決め打ちじゃないのと Windows の場合に vendor/phpunit/phpunit/phpunit を探してくれるのは便利です。Phar もプロジェクトディレクトリ内に置いとけば自動で見つけてくれます。

リモート実行する場合、次のように任意のコマンドが指定できるので、Windows 上から WSL の docker-compose を実行すれば OK です。テストファイルを指定して実行するときのために phpunit.paths も指定する必要があります。

{
    "phpunit.command": "wsl direnv exec . docker-compose run --rm app",
    "phpunit.phpunit": "vendor/bin/phpunit",
    "phpunit.paths": {
        "${workspaceFolder}": "/app",
    }
}

SSH や Docker で実行するための別の設定 phpunit.ssh とか phpunit.docker.container とか docker.image とかもありますが、phpunit.command だけで指定するほうが簡単なんじゃないかと思います。

メソッドを指定して実行するときにメソッド名が単純に --filter に渡されるので、例えば hoge というメソッドのテストを実行するときに hogehoge も一緒に実行されてしまいます。変に頑張られると Windows と Linux でのシェルのクオートやエスケープの違いでうまく動かなくなったりするので、これでも十分かなと言う気もします。

phpunit.args 設定で PHPUnit のコマンドラインオプションを配列で指定できます。配列なので適切なエスケープが施されるか、あるいはシェルを経由しないのかと思いきや、実際には単純に join でスペース区切りで繋げられてシェル経由で実行されます。これ配列である必要全然無いような。。。むしろ phpunit.command phpunit.phpunit phpunit.args をまとめて1つの文字列の設定でも十分な気もします。実際のところ次のように適当にバラけさせても動きます。

{
    "phpunit.command": "wsl direnv",
    "phpunit.phpunit": "exec .",
    "phpunit.args": ["docker-compose", "run", "--rm", "app", "vendor/bin/phpunit"],
    "phpunit.paths": {
        "${workspaceFolder}": "/app",
    },
}

リモート実行のために phpunit.paths でパスのマッピングをしているとき、テストがコケたときに赤線がついたり問題タブに表示されたりするのが機能しなくなることがあります。下記で /app がハードコードされているためなので Docker 内では /app にソースを配置すれば大丈夫です。

PHPUnit は拡張が直接実行しているわけではなく、VSCode の ShellExecution タスクとして実行するようになっています。problemMatchers という VSCode の拡張ポイントを使って実行結果から正規表現でコケたテストケースのファイルや行番号やメッセージを抜き出すようになっているようです。この problemMatchers は動的にはできなさそうなのでこのような動作になっているようです。

calebporzio.better-phpunit

デフォだと vendor/bin/phpunit が(Windows なら vendor/bin/phpunit.bat)が決め打ちで実行されます。 better-phpunit.phpunitBinary でコマンドは変更できます。これはシェルで解釈されるので Windows なら php vendor/phpunit/phpunit/phpunit とかでも OK です。

Docker などでリモート実行するときは次のようにコマンドを指定します。ローカルとリモートのファイル名のマッピングで ${workspaceFolder} は使用できないので次のように絶対パスで指定する必要があります。なお、Windows でもパス区切りは \\ ではなく / で指定します。

{
    "better-phpunit.phpunitBinary": "vendor/bin/phpunit",
    "better-phpunit.docker.enable": true,
    "better-phpunit.docker.command": "wsl direnv exec . docker-compose run --rm app",
    "better-phpunit.docker.paths": {
        "c:/Users/oreore/path/to/project": "/app"
    },
}

better-phpunit.docker.commandbetter-phpunit.phpunitBinary は単にスペース区切りで連結されるだけです。なので次のように適当にバラしても通ります。

{
    "better-phpunit.docker.enable": true,
    "better-phpunit.docker.command": "wsl direnv exec .",
    "better-phpunit.phpunitBinary": "docker-compose run --rm app vendor/bin/phpunit",
    "better-phpunit.docker.paths": {
        "c:/Users/oreore/path/to/project": "/app"
    },
}

設定の項目名に docker と付いていますが Docker でなくても動きます。例えば次のようにすれば Windows 上の VSCode から WSL 上の PHPUnit を実行できます。

{
    "better-phpunit.docker.enable": true,
    "better-phpunit.docker.command": "wsl php",
    "better-phpunit.phpunitBinary": "vendor/bin/phpunit",
    "better-phpunit.docker.paths": {
        "c:/Users": "/c/Users"
    },
}

次のようにすればローカルの Windows 上の PHP が実行されます。つまり最初の better-phpunit.phpunitBinary だけ指定したときと同じです。

{
    "better-phpunit.docker.enable": true,
    "better-phpunit.docker.command": "php",
    "better-phpunit.phpunitBinary": "vendor/phpunit/phpunit/phpunit",
}

コケたテストに赤線をつけたり問題タブに表示したりする機能が機能していないようです。たぶん package.jsonproblemMatchers のパターンが間違っているためだと思います。また、仮に problemMatchers が修正されたとしても「リモートの実行結果のパス → ローカルのパス」のマッピングはサポートしてなさそうなので、リモート実行だと赤線や問題タブには表示されません。

なお、emallin.phpunit と同様で PHPUnit は拡張が直接実行しているわけではなく、VSCode の ShellExecution タスクとして実行するようになっています。problemMatchers という拡張ポイントを使って実行結果から正規表現でコケたテストケースのファイルや行番号やメッセージを抜き出すようになっているようです。

recca0120.vscode-phpunit

サイドバーにテストのエクスプローラーが表示できたり、CodeLenses でコード上からサクッと実行できたり、多機能です。

前述の 2 つと比べるとだいぶ複雑です。内部では LanguageServer を実行していて PHPUnit は LanguageServer から child_process.spawn で直接実行されています。コケたテストのファイル名や行番号やメッセージは PHPUnit で出力した JUnit XML から抜き出しているようです。

素のままだとデフォで vendor/bin/phpunit (Windows なら vendor/bin/phpunit.bat)を実行します。設定の phpunit.phpunit で変更できますが、絶対パスで指定する必要があります。相対パスで指定しても頭に / が付けられてしまいます。

phpunit.php で PHP のバイナリを指定すれば PHPUnit のコマンドを直接ではなく明示的に PHP コマンドで実行されるようになります。Windows で Phar を指定するときに使えそうです。なお、これも絶対パスで指定する必要があります。パスが通ったところにあるコマンドを指定しても頭に / が付けられるのでダメです。

phpunit.args で PHPUnit のコマンドラインオプションが指定できます。phpunit.args-c が含まれないときは phpunit.xml.dist または phpunit.xml が検索され、見つかればコマンドラインに自動で追加されます。これも絶対パスで追加されます。

テストファイルを指定して実行するとき、phpunit.relativeFilePath: true に設定されていると相対パスで指定されるようになります。リモート実行するときはローカルの絶対パスが指定されてもダメなのでこのオプションが必須です。ただし前述の phpunit.xml が自動で追加されるときは絶対パスのままなので、リモート実行させるなら phpunit.args-c オプションを指定する必要があります。

Windows で実行するとき、パス区切り文字はバックスラッシュのままです。なので Windows から Docker などでリモート実行させるのは難しいです。

phpunit.shell でシェルを経由するかどうかが指定できます。これはそのまま child_process.spawn のオプションになります。

カーソル位置のテストを実行や、テストエクスプローラーや CodeLenses で単一のメソッドのテストを実行する機能が機能していません。たぶん下記の問題だと思いますが・・

WSL 上で VSCode を実行しているならこれで解決します。

{
    "phpunit.shell": "bash"
}

Windows 上で VSCode を実行しているときはどうしようもありません。不便なので治しておきました。

もうマージされてリリースされているようなのでこの問題は解決していると思います。

さいごに

recca0120.vscode-phpunit が高機能で IDE っぽい感じで良さそうですが、Windows 上の VSCode からのリモート実行が絶望的です。Remote Development とかで Docker 内で VSCode を動かせばいいんでしょうけど。

emallin.phpunit は Docker 内で /app にコードを置く必要がある、という問題はありますが・・それさえ目をつぶれば一番安定して使えそうです。

calebporzio.better-phpunit はコケたテストの赤線や問題タブが機能しないので微妙です。それ以外は動作原理は emallin.phpunit とほぼほぼ同じで違いがあまりありません。

NodeJS で AsyncLocalStorage を使って横断的なトランザクション

NodeJS で Async hooks を使うトランザクションの実用的な実装 - ngyukiの日記 ですが、Twitter で AsyncLocalStorage なるものの存在を教えてもらいました。

AsyncLocalStorage を使うほうがスッキリできました。transaction の方はあまり変わっていませんが connection のほうがスッキリしています。

// conext.ts

import { AsyncLocalStorage } from 'async_hooks'

interface Pool<T extends Connection> {
    getConnection(): Promise<T>,
    end(): Promise<void>;
}

interface Connection {
    beginTransaction(): Promise<void>,
    commit(): Promise<void>,
    rollback(): Promise<void>,
    release(): void,
}

type PromiseValue<T> = T extends Promise<infer V> ? V : never;

type PoolConnection<TPool extends Pool<Connection>> = PromiseValue<ReturnType<TPool["getConnection"]>>;

export class Conext<
    TPool extends Pool<TConnection>,
    TConnection extends Connection = PoolConnection<TPool>
> {
    private asyncLocalStorage: AsyncLocalStorage<{conn: TConnection, trx: number}>;

    constructor(private pool: TPool) {
        this.asyncLocalStorage = new AsyncLocalStorage();
    }

    async connection<T>(callback: (conn: TConnection) => Promise<T>) {
        const context = this.asyncLocalStorage.getStore();
        if (context) {
            return await callback(context.conn);
        }
        const conn = await this.pool.getConnection();
        try {
            const context = { conn: conn, trx: 0 };
            return await this.asyncLocalStorage.run(context, async () => {
                return await callback(conn);
            });
        } finally {
            conn.release();
        }
    }

    async transaction<T>(callback: (conn: TConnection) => Promise<T>) {
        return await this.connection(async (conn) => {
            const context = this.asyncLocalStorage.getStore()!;
            if (context.trx === 0) {
                context.trx++;
                await conn.beginTransaction();
                try {
                    const ret = await callback(conn);
                    await conn.commit();
                    return ret;
                } catch (err) {
                    await conn.rollback();
                    throw err;
                } finally {
                    context.trx--;
                }
            } else {
                context.trx++;
                try {
                    return await callback(conn);
                } finally {
                    context.trx--;
                }
            }
        });
    }

    async end() {
        try {
            return await this.pool.end();
        } finally {
            this.asyncLocalStorage.disable();
        }
    }
}

これ、async_hooks モジュールに含まれていて同じページのリファレンスにあるじゃないですか・・Async hooks の存在を知って、これ超便利じゃーん、と高まったテンションでそのまま試行錯誤していたので一番下まで読んでませんでした・・

asyncHook を直で使うとぱっと見なにをやっているのか判りにくいですが、AsyncLocalStorage ならなるほど他言語でいうところの TLS のようなものかと理解できますね(スレッドじゃないけど)。

NodeJS で Async hooks を使うトランザクションの実用的な実装

前に書いた NodeJS で実験的な Async hooks を使って横断的なトランザクション をもう少し実用的な実装にしてみました。とは言っても NodeJS をプロダクション用に書くことが全くないのでホントに実用的になってるかどうかはわかりませんが。

// context.ts

import asyncHooks from 'async_hooks'

interface Pool<T extends Connection> {
    getConnection(): Promise<T>,
    end(): Promise<void>;
}

interface Connection {
    beginTransaction(): Promise<void>,
    commit(): Promise<void>,
    rollback(): Promise<void>,
    release(): void,
}

type PromiseValue<T> = T extends Promise<infer V> ? V : never;

type PoolConnection<TPool extends Pool<Connection>> = PromiseValue<ReturnType<TPool["getConnection"]>>;

export class Conext<
    TPool extends Pool<TConnection>,
    TConnection extends Connection = PoolConnection<TPool>
> {
    private readonly context: {[eid: string]: { conn: TConnection, trx: number }} = {};

    private asyncHooks: asyncHooks.AsyncHook;

    constructor(private pool: TPool) {
        this.asyncHooks = asyncHooks.createHook({
            init: (asyncId, type, triggerAsyncId, resource) => {
                if (this.context[triggerAsyncId]) {
                    this.context[asyncId] = this.context[triggerAsyncId];
                }
            },
            destroy: (asyncId) => {
                delete this.context[asyncId];
            }
        }).enable();
    }

    async connection<T>(callback: (conn: TConnection) => Promise<T>) {
        const eid = asyncHooks.executionAsyncId();
        if (this.context[eid]) {
            return await callback(this.context[eid].conn);
        }
        const conn = await this.pool.getConnection();
        try {
            const eid = asyncHooks.executionAsyncId();
            this.context[eid] = { conn: conn, trx: 0 };
            try {
                return await callback(conn);
            } finally {
                delete this.context[eid];
            }
        } finally {
            conn.release();
        }
    }

    async transaction<T>(callback: (conn: TConnection) => Promise<T>) {
        return await this.connection(async (conn) => {
            const eid = asyncHooks.executionAsyncId();
            if (this.context[eid].trx === 0) {
                this.context[eid].trx++;
                await conn.beginTransaction();
                try {
                    const ret = await callback(conn);
                    await conn.commit();
                    return ret;
                } catch (err) {
                    await conn.rollback();
                    throw err;
                } finally {
                    this.context[eid].trx--;
                }
            } else {
                this.context[eid].trx++;
                try {
                    return await callback(conn);
                } finally {
                    this.context[eid].trx--;
                }
            }
        });
    }

    async end() {
        try {
            return await this.pool.end();
        } finally {
            this.asyncHooks.disable();
        }
    }
}

ジェネリクスを用いて mysql や mariadb のパッケージに直接依存しないようにしました。

使い方

コネクションプールを作成し、それをコンストラクタに渡して Conext のインスタンスを作ります。 このインスタンスは例のようにパッケージスコープでエクスポートしてもいいですし、DIコンテナを使っているならDIコンテナに入れるなどしても良いと思います。

// db.ts

import mysql from 'mariadb'
import { Conext } from './conext'

const dbConfig: mysql.PoolConfig = {
    host: process.env.MYSQL_HOST,
    port: parseInt(process.env.MYSQL_PORT || ''),
    user: process.env.MYSQL_USER,
    password: process.env.MYSQL_PASSWORD,
    database: process.env.MYSQL_DATABASE,
}

const pool = mysql.createPool(dbConfig);

export default new Conext(pool);

実際に使ってみる例です。トランザクションが必要ないときは connection を、トランザクションが必要なときは transaction を使います。どちらの場合もそのスコープ、およびそのスコープから呼ばれた同期/非同期関数では同じ接続が使用されます。

// index.ts

import db from './db'

(async () => {

    const func1 = async () => {
        // このスコープの中では同じコネクションが使用される
        await db.connection(async (conn) => {
            const [{cid}] = await conn.query('select connection_id() as cid');
            console.log(`${cid} -> func1`);

            await conn.query('select sleep(1)');
            await func2();
        });
    };

    const func2 = async () => {
        // ネストされたトランザクションは一番外側でのみ begin/commit される
        await db.transaction(async (conn) => {
            const [{cid}] = await conn.query('select connection_id() as cid');
            console.log(`${cid} -> func2`);

            await conn.query("insert into t (name) values ('aaa')");
            await conn.query('select sleep(1)');
            await func3();
        });
    };

    const func3 = async () => {
        // このトランザクションは func2 の内側なので begin/commit されない
        await db.transaction(async (conn) => {
            const [{cid}] = await conn.query('select connection_id() as cid');
            console.log(`${cid} -> func3`);

            await conn.query("insert into t (name) values ('zzz')");
        });
    };

    try {
        // 5並行で func1 -> func2 -> func3 を実行
        // 5接続しか使用されず func1/func2/func3 では同じ接続が使用される
        await Promise.all(Array.from(Array(5)).map(async () => func1()));

        // 30並行でクエリを実行
        // プールの接続数のデフォが10なので10接続しか使用されない
        // 1クエリで1秒かかるので合計で3秒ぐらいかかる
        const start = new Date().getTime();
        const ids = await Promise.all(Array.from(Array(30)).map(async () => {
            return await db.connection(async (conn) => {
                await conn.query('select sleep(1)');
                const [{cid}] = await conn.query('select connection_id() as cid');
                return cid;
            });
        }));
        console.log(`connection_ids:`, new Set(ids))
        console.log(`duration: ${new Date().getTime() - start} ms`);

    } catch (err) {
        console.error(err);
    } finally {
        await db.end();
    }
})()

さいごに

NodeJS のサーバサイドだとどうしてもコンテキスト的なものを引き回す必要があるのだと思っていたのですが、Async hooks なんて便利なものが(Experimental だけど)出来ていたんですね。

Async hooks は 3 年ぐらい前に出てきたもののようです。NodeJS のトレンドは全然追えていませんが、NodeJS のフレームワークでは Async hooks がもっと活用されてくるようになってきたりするのでしょうか。

NodeJS で実験的な Async hooks を使って横断的なトランザクション

NodeJS で非奨励の Domain を使ってコンテキスト的なものを持ち回すことなく横断的なトランザクション というのをやってみましたが、よくよく見てみれば Async hooks でも同じことができそうでした。

Domain は Deprecated でしたが Async hooks は Experimental なのでこっちのほうが良いです。しかも下記のコードを見るに Domain も実は Async hooks で実装されているんですね。

例えば次のように非同期コールバックの中で外側のコンテキスト的なものを取り出すことができます。

import asyncHooks from 'async_hooks'

const contexts: {[eid: string]: string} = {};

const asyncCall = async () => {
    return new Promise((r) => {
        setTimeout(() => {
            // 現在の ID を元にコンテキストを取り出す
            const eid = asyncHooks.executionAsyncId();
            const context = contexts[eid];
            console.log(`ここは ${context} の中です`);
            r();
        }, 1)
    });
};

(async () => {
    asyncHooks.createHook({
        init(asyncId, type, triggerAsyncId, resource) {
            // 新しい非同期リソースが作られたときに作成元のコンテキストをコピーする
            if (contexts[triggerAsyncId]) {
                contexts[asyncId] = contexts[triggerAsyncId];
            }
        },
        destroy(asyncId) {
            delete contexts[asyncId];
        }
    }).enable();

    const arr = [
        (async () => {
            // 現在の ID を元にコンテキストを設定
            const eid = asyncHooks.executionAsyncId();
            contexts[eid] = 'AAA';
            await asyncCall();
        })(),
        (async () => {
            // 現在の ID を元にコンテキストを設定
            const eid = asyncHooks.executionAsyncId();
            contexts[eid] = 'BBB';
            await asyncCall();
        })(),
    ];

    await Promise.all(arr);
    // => ここは AAA の中です
    // => ここは BBB の中です
})()

例えるなら executionAsyncId() が現在のスレッド ID で、init フックはスレッドが新しく作成されたときに実行され、asyncId が新しいスレッドの ID、triggerAsyncId がスレッドを作成した元のスレッドの ID、みたいに理解するとわかりやすいでしょうか。スレッドではないけど(1つの非同期リソースから複数回コールバックが実行されることもあるのでたとえ話にしてもこの説明は正しくない)。

前回と同じようなトランザクションは次のように実装できます。

import mysql from 'mariadb'
import asyncHooks from 'async_hooks'

const connections: {[eid: string]: mysql.PoolConnection} = {};

const transaction = async (
    pool: mysql.Pool,
    callback: (conn: mysql.PoolConnection) => Promise<void>
) => {
    const conn = await pool.getConnection();
    try {
        const eid = asyncHooks.executionAsyncId();
        connections[eid] = conn;
        try {
            await conn.beginTransaction();
            try {
                await callback(conn);
                await conn.commit();
            } catch (err) {
                await conn.rollback();
                throw err;
            }
        } finally {
            delete connections[eid];
        }
    } finally {
        conn.release();
    }
}

const getCurrentConnection = () => {
    const eid = asyncHooks.executionAsyncId();
    return connections[eid];
}

const insert = async () => {
    const conn = getCurrentConnection();
    await conn.query("insert into t values (null, 'xxx')");

    throw new Error('oops!!!');
};

(async () => {
    asyncHooks.createHook({
        init(asyncId, type, triggerAsyncId, resource) {
            if (connections[triggerAsyncId]) {
                connections[asyncId] = connections[triggerAsyncId];
            }
        },
        destroy(asyncId) {
            delete connections[asyncId];
        }
    }).enable();

    const pool = mysql.createPool(dbConfig);
    try {
        await transaction(pool, async () => {
            // コネクションとかトランザクションとかのオブジェクトを渡す必要無し
            await insert();
        });
    } catch (err) {
        console.error(err);
    } finally {
        await pool.end();
    }
})();

さいごに

というかググると Async hooks の例がたくさん出てくるので割とありふれたもののようです。Domain なんてのを知ってはしゃいでた自分が周回遅れなだけでした・・NodeJS、完全にすら理解できていなかった・・

以下の記事での内容から察するに、かつては Domain は setTimeout を書き換えるような方法で実装されていたのでしょうか。

Async hooks が出てきてからは Domain の実装も Async hooks に置き換わった、ということのようです、たぶん。

AWS の Billing Alert(請求アラート)を日々の増加分で設定する

AWS の請求アラートは昔は CloudWatch Alarm を使う必要がありましたが、最近なら AWS Budgets で簡単に設定できます。便利です。

がしかし、AWS Budgets はもっとも短いスパンでも月次の請求額なので、次のようなケースにはフィットしません。

  • 検証用の AWS アカウントなので普段はインスタンスなどを立ち上げておらずまったく費用はかからない
  • たまに検証のためにインスタンスなどを立ち上げたとしてもその日のうちに削除して立ち上げっぱなしにはしない

このケースでアラートしてほしいのは「インスタンスなどを削除し忘れていて課金されっぱなしになっている」ということなので、月次の請求額のアラートではあまり意味がありません。

(もっとも検証用のアカウントでも、だいたい月にこれくらいまで、という予算はあると思うのでそれを超えないようにするための月次のアラートはそれはそれで有用だと思いますが)

ので、CloudWatch Alarm で請求額の日々の増加分に対してアラートを仕込んでみました。

Terraform のテンプレートは次のような感じ。

provider "aws" {
  alias  = "us-east-1"
  region = "us-east-1"
}

variable "alarm_name" {}

resource "aws_sns_topic" "billing" {
  provider = aws.us-east-1
  name     = "BillingAlarm"
}

resource "aws_cloudwatch_metric_alarm" "billing_rate" {
  provider            = aws.us-east-1
  alarm_name          = var.alarm_name
  alarm_description   = "Billing rate over 0.1 USD in 6 hours"
  comparison_operator = "GreaterThanThreshold"
  evaluation_periods  = 1
  datapoints_to_alarm = 1
  threshold           = 0.1
  alarm_actions       = [aws_sns_topic.billing.arn]

  metric_query {
    id = "m1"

    metric {
      namespace   = "AWS/Billing"
      metric_name = "EstimatedCharges"
      period      = 6 * 60 * 60
      stat        = "Maximum"
      dimensions = {
        Currency = "USD"
      }
    }
  }

  metric_query {
    id         = "e1"
    expression = "RATE(m1) * 60 * 60 * 6"
  }

  metric_query {
    id          = "e2"
    expression  = "IF(e1>0, e1, 0)"
    label       = "USD/24H"
    return_data = true
  }
}

RATE 関数でメトリクスの直前値との差分を1秒あたりに換算した変化分が得られます。これに 60 * 60 * 6 して 6 時間分の増分にしています。日々の、と言っておきながら実際には 6 時間分のものになっていますが、請求額のメトリクスがだいたい 6 時間ごとの値なのでそれに合わせています。

月替りでメトリクスが 0 にリセットされるためその際に RATE がマイナス値になります。アラートしきい値としてはそのままでも問題ありませんが、グラフで見たときに変なので IF(e1>0, e1, 0) で 0 以下のメトリクスは 0 としています。

さいごに

結構前から仕込んでいていい感じにアラートしてくれていたのですが、

AWS Budgets で日次予算でアラートが仕込めるようになったようです。よってこの記事の内容はもう用済みです。