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 に置き換わった、ということのようです、たぶん。