📑

# 桐をWebで蘇らせる ― DataDrawers開発記-第5回

に公開

第5回: Turbopack のメモリ制限との遭遇

謎の警告メッセージ

ページネーション方式に変更した後、19万件のデータでテストしていると、突然ターミナルに見慣れない警告が表示された:

⚠ Server is approaching the used memory threshold, restarting...

「え? なんだこれ?」

Next.jsの開発サーバーが自動的に再起動し、進行中だったインポート処理が中断された。

当時はまだこのメッセージの意味を完全には理解していなかった。「メモリが閾値に近づいている? でもNode.jsはまだ1GB程度しか使っていないはず...」

再度実行すると、また同じタイミングで再起動される。何度試しても、19万件付近で必ず中断される。


Turbopack のメモリ監視機能

調べてみると、これはTurbopack(Next.js 16の新しい開発サーバー)の機能だった。

Turbopackは、開発体験を向上させるため、Node.jsプロセスのメモリ使用状況を常時監視している。そして、メモリ使用量が一定の閾値(デフォルトでは約1.5GB前後)に達すると、開発サーバーを自動的に再起動して、メモリリークや不安定な状態を回避する仕組みになっている。

この機能自体は素晴らしい。通常の開発では、ホットリロードを繰り返すことでメモリが徐々に蓄積し、最終的にクラッシュすることがある。それを防ぐための自動再起動は理にかなっている。

しかし、長時間実行されるバックグラウンドジョブにとっては、致命的な障害になる。

当時の実装: APIルート内での処理

当時のコードは、Next.jsのAPIルート内でインポート処理を実行していた:

// app/api/db/sync/route.ts (失敗版)
export async function POST(request: NextRequest) {
  const { tableName, tableId } = await request.json();

  // ❌ APIルート内で長時間処理
  const totalCount = await getTableCount(tableName);

  let offset = 0;
  while (offset < totalCount) {
    const chunk = await fetchTableDataPaginated(tableName, offset, 50000);
    await insertTableData(tableId, chunk);
    offset += chunk.length;
  }

  return NextResponse.json({ success: true });
}

この方式の問題点:

  1. APIルートはリクエスト単位の処理を想定している
    • 数秒〜数十秒で完了する処理が前提
    • 124万件のインポートは数分かかる
  2. 開発サーバーのメモリ閾値に引っかかる
    • 19万件(約400MB相当)でTurbopackが再起動
    • バックグラウンドジョブが中断される
  3. ブラウザのタイムアウト
    • フロントエンドは fetch() でAPIを呼んでいる
    • 長時間レスポンスがないとタイムアウト

バックグラウンドジョブ方式の試み

「APIルートで直接処理するのがダメなら、バックグラウンドジョブにすればいい」

そう考えて、以下のような実装を試した:

// lib/import-job-processor.ts (失敗版)
import { PrismaClient } from '@prisma/client';

const prisma = new PrismaClient();

export async function processImportJob(jobData: ImportJobData) {
  const { tableName, tableId } = jobData;

  console.log(`[ImportJob] 開始: ${tableName}`);

  const totalCount = await getTableCount(tableName);
  let offset = 0;

  while (offset < totalCount) {
    const chunk = await fetchTableDataPaginated(tableName, offset, 50000);
    await insertTableData(tableId, chunk);

    offset += chunk.length;

    // 進捗をDBに保存
    await prisma.importJob.update({
      where: { id: jobData.jobId },
      data: {
        processedRows: offset,
        progress: Math.floor((offset / totalCount) * 100)
      }
    });
  }

  console.log(`[ImportJob] 完了: ${tableName}`);
}

そして、APIルートはすぐにレスポンスを返し、バックグラウンドで処理を実行する:

// app/api/jobs/start/route.ts (失敗版)
export async function POST(request: NextRequest) {
  const { tableName, tableId } = await request.json();

  const jobId = `job_${Date.now()}`;

  // ジョブレコードを作成
  await prisma.importJob.create({
    data: { id: jobId, tableName, status: 'pending' }
  });

  // ❌ バックグラウンドで実行
  processImportJob({ jobId, tableName, tableId }).catch(err => {
    console.error('[Job Error]', err);
  });

  // すぐにレスポンスを返す
  return NextResponse.json({ success: true, jobId });
}

フロントエンドは、jobId を使ってポーリングで進捗を確認する仕組みだった:

// components/import-table-dialog.tsx (失敗版)
useEffect(() => {
  if (!currentJobId) return;

  const pollInterval = setInterval(async () => {
    const response = await fetch(`/api/jobs/${currentJobId}`);
    const result = await response.json();

    if (result.job.status === 'completed') {
      clearInterval(pollInterval);
      window.location.reload();
    }

    setJobProgress(result.job.progress);
  }, 2000); // 2秒ごとにポーリング

  return () => clearInterval(pollInterval);
}, [currentJobId]);

「これで完璧だ」

そう思って実行すると...

[ImportJob] 開始: dbo.指導要録
進捗: 50,000 / 1,240,703 (4%)
進捗: 100,000 / 1,240,703 (8%)
進捗: 150,000 / 1,240,703 (12%)
進捗: 190,000 / 1,240,703 (15%)

⚠ Server is approaching the used memory threshold, restarting...

[開発サーバー再起動]
[ImportJob] 状態: pending のまま

バックグラウンドジョブも、開発サーバーと共に停止してしまった。


「開発サーバー内での大量データ処理は不可能」という結論

この時点で、重要な事実に気づいた:

Turbopack(Next.js開発サーバー)内で、長時間・大量メモリを消費する処理を実行することはできない

理由:

  1. メモリ監視による自動再起動
    • 閾値に達すると問答無用で再起動
    • 設定変更不可(現時点では)
  2. 開発サーバーは開発用途に最適化されている
    • ホットリロード、高速リビルドが目的
    • 本番運用のような長時間処理は想定外
  3. 本番ビルド(next build + next start)なら問題ない
    • しかし、開発中はTurbopackを使いたい
    • ビルド時間が長くなるのは避けたい

「Turbopack内でのバックグラウンドジョブは無理だ。別のアプローチが必要だ」


アーキテクチャの再設計へ

ここまでの試行錯誤で、以下のことが明確になった:

失敗した方法 問題点
全データ一括読み込み メモリ不足でクラッシュ(90万件)
ROW_NUMBER()ページネーション 77万件で停止(不安定)
APIルート内で処理 ブラウザのタイムアウト、Turbopack再起動
Turbopack内バックグラウンドジョブ メモリ閾値で自動再起動(19万件)

「開発サーバーとは完全に独立したプロセスで処理を実行するしかない」

この結論に至った時、頭に浮かんだのが child_process.spawn() だった。

Node.jsの spawn() を使えば、親プロセス(Next.js開発サーバー)から完全に独立した子プロセスを起動できる。そして、detached: true オプションを指定すれば、親が終了しても子プロセスは継続実行される。

「これだ。独立したワーカープロセスで実装しよう」
次回、いよいよ最終的な解決策を実装していく。


教訓

この段階で学んだことは:

  1. 開発環境と本番環境の違いを意識する
    • Turbopackの機能は開発体験向上のため
    • 長時間処理は開発サーバーの想定外
  2. 適切なアーキテクチャを選択する
    • APIルート: 短時間のリクエスト/レスポンス
    • バックグラウンドジョブ: 長時間の非同期処理
    • 独立プロセス: 開発サーバーと分離した重い処理
  3. 制約を理解し、回避策を考える
    • Turbopackのメモリ制限は変更不可
    • それなら、Turbopackの外で処理すればいい

「失敗は学びの宝庫だ」
この言葉を実感しながら、次のステップへ進んだ。

参考リンク

Discussion