Node.js/TypeScriptにおける複数の非同期リソース(DB、サーバー、ファイル)の確保・解放フローを示す図です。失敗時の安全な巻き戻しや、終了時の逆順クリーンアップが視覚的に表現されています。

Summary

ローカル DB を起動して、認証モックの HTTP サーバを立ち上げ、一時的な環境ファイルを書く——テストや開発用ツールでこういう「複数の非同期リソースを順番に確保するスクリプト」を書くと、途中で失敗したときに既に確保したリソースを片付ける処理を入れ忘れがちです。本記事では、確保途中の失敗時にも安全に巻き戻し、終了時にも逆順で解放する実装パターンを紹介します。

こんな人におすすめ

  • E2E のセットアップスクリプトで「途中で落ちるとプロセスやポートが残る」現象に困っている方
  • 複数の非同期リソース(DB、HTTP サーバ、一時ファイル)を順番に立ち上げる関数を書く方
  • try/finally で雑に書いていたが、もう少し堅い構造にしたい Node.js / TypeScript 開発者

全体像

prepareEnvironment()
  ├─ resource1 確保
  ├─ resource2 確保(失敗時:resource1 を解放)
  ├─ resource3 確保(失敗時:resource2, resource1 を解放)
  └─ return { cleanup }   ← cleanup は確保順の逆で解放

呼び出し側:
  const env = await prepareEnvironment()
  try {
    // 本番処理
  } finally {
    await env.cleanup()
  }

確保順と逆順で解放するのが基本原則です。後から確保したリソースが、前に確保したリソースに依存している可能性があるためです。

エントリの責任ある cleanup パターン

非同期リソースを順次確保する関数は、確保途中で失敗したら確保済みのリソースを必ず解放するように書きます。

export async function prepareEnvironment(repoRoot) {
  const dbUrl = await ensureLocalDatabaseUrl(repoRoot)
  await resetLocalDatabase(repoRoot)
  const authMock = await startAuthMock(dbUrl)

  const runtimeEnv = {
    DB_URL: dbUrl,
    AUTH_BASE_URL: authMock.baseUrl,
    // ...
  }

  let envFiles
  try {
    envFiles = await stageEnvironmentFiles(repoRoot, runtimeEnv)
  } catch (error) {
    await authMock.cleanup()  // ★ envFiles の確保失敗時は authMock を片付ける
    throw error
  }

  return {
    runtimeEnv,
    cleanup: async () => {
      await envFiles.cleanup()  // ★ 確保順の逆で解放
      await authMock.cleanup()
    },
  }
}

ここで重要なのは2点です。

(a) 各 cleanup は再入可能にする:1つのリソース専用にして、複数回呼ばれても安全(idempotent)に書きます。server.close() 系は close 済みの場合にエラーにならないように分岐を入れることもあります。

(b) 呼び出し側は try/finally で必ず後片付けする:以下のように書きます。

const env = await prepareEnvironment(repoRoot)
try {
  // 本番処理(テスト実行など)
} finally {
  await env.cleanup()
}

状態解決のフォールバック

リソースが「既に整っているなら使う、なければ用意する」というケースは多いです。リトライループを書くより、2段階のフォールバックで素直に書く方が読めます。

async function ensureLocalDatabaseUrl(repoRoot) {
  // 環境変数で明示されていれば最優先
  const explicit = process.env.DB_URL?.trim()
  if (explicit) return explicit

  // 1回目: 既に起動していれば取れる
  const first = await readDatabaseStatus(repoRoot)
  if (first.DB_URL) return first.DB_URL

  // 起動して2回目: 起動成功後に再取得
  await startDatabase(repoRoot)
  const second = await readDatabaseStatus(repoRoot)
  if (second.DB_URL) return second.DB_URL

  throw new Error('Unable to resolve DB_URL.')
}

リトライ無限ループにせず、「明示 → 既存 → 起動して再取得」の3段で諦めるのが運用上扱いやすいです。

ポート割り当ては OS に任せる

HTTP サーバを立ち上げる場合、固定ポートにすると衝突します。listen(0) で OS に空きポートを割り当てさせるのが基本です。

async function startAuthMock(dbUrl) {
  const pool = new Pool({ connectionString: dbUrl, max: 1 })
  const server = createServer((req, res) => {
    // 必要なエンドポイントだけ実装
  })
  await new Promise((resolve) => server.listen(0, resolve))
  const baseUrl = `http://127.0.0.1:${server.address().port}`

  return {
    baseUrl,
    cleanup: async () => {
      await new Promise((resolve) => server.close(resolve))
      await pool.end()
    },
  }
}

server.address().port で OS が割り当てたポート番号を取得し、URL を組み立てて返します。

アンチパターン

try/finally で全部一緒に書く

// ❌ こうやって書くと cleanup の責務が呼び出し側に染み出す
let resource1, resource2
try {
  resource1 = await acquire1()
  resource2 = await acquire2()
  // 業務ロジック
} finally {
  if (resource2) await resource2.close()
  if (resource1) await resource1.close()
}

業務ロジックの行数が増えるたびに finally 節が肥大化し、複数箇所に同じ acquire/cleanup ロジックが散らばります。エントリ関数で一括管理する方が再利用しやすいです。

cleanup を呼ばない

return { ... } した時点で「呼び出し側が cleanup を必ず呼ぶ」前提を書面で残さないと、忘れられます。型で using パターンや TypeScript 5.2+ の Symbol.asyncDispose を使う選択肢もあります。

グローバル変数で共有

複数のテストやスクリプトでリソースを共有するために global.dbConnection のような書き方をすると、並行実行や再実行で破綻します。エントリ関数で都度確保してクリーンに使う方が安全です。

まとめ

複数の非同期リソースを安全に扱うには、次のパターンを意識します。

  • 確保順序と逆順で解放する cleanup を返す関数を作る
  • 確保途中で失敗したら、それまでに確保したリソースを try/catch で巻き戻す
  • 呼び出し側は try/finally で必ず cleanup() を呼ぶ
  • 状態解決は「明示→既存→起動して再取得」の段階フォールバックで
  • ポートは listen(0) で OS に割り当てさせる

このテンプレートに乗せておくと、E2E セットアップだけでなく、外部サービス連携や統合テストのリソース管理にも使い回せます。