0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

カスケード削除を冪等にする + 段階別 transaction でロック時間を最小化する設計

0
Posted at

この記事は約 5 分で読めます。

筆者プロフィール: ソフトウェアエンジニア。「知った気にならない。いつまでも学び続ける」を信条に、業務と個人開発の両輪で技術を磨いています。AI 駆動開発で複数の個人開発アプリを構築・運用中。
👉 ポートフォリオ: 筆者ホームページ

プロジェクト削除で 大量の関連エンティティ を一気に消すとき、ネットワーク障害や DB ロック競合でリトライが必要になることがあります。本記事では、運用中の SaaS 「たすきば Knowledge Relay」 で採用した 冪等カスケード削除 + 段階別 transaction (ADR-0015) の設計を整理します。

サービスの機能紹介・画面イメージ・コンセプトは公式プロダクトページをご覧ください。
👉 たすきば Knowledge Relay — 公式プロダクトページ

カスケード削除の難しさ

たすきばのプロジェクト削除は、以下を連鎖的に削除します。

Project deleted
├─ Tasks (数百〜数千件)
├─ Knowledges (数十〜数百件)
├─ Risks
├─ Issues
├─ Retrospectives
├─ Memos
├─ ProjectMembers
├─ Attachments
├─ ApiCallLogs (関連分)
├─ AuditLogs (関連分は保持、削除しない)
└─ ...

これを 冪等 + 高速 + 安全 に行う設計が必要。


1. なぜ冪等性が必要か

カスケード削除は途中で失敗する可能性があります。

失敗シナリオ 内容
ネットワーク障害 リトライが必要
DB ロック競合 トランザクション失敗
アプリ再起動 中断

冪等でないと、リトライで状態が二重に変わる可能性。冪等な設計なら、「既に削除済みの項目には何もしない」「未削除の項目だけを削除する」を保証できます。


2. 冪等カスケードの実装

export async function deleteProjectCascade(
  projectId: string,
  context: { actorUserId: string; viewerTenantId: string },
) {
  const now = new Date();

  await prisma.$transaction(async (tx) => {
    await tx.task.updateMany({
      where: {
        projectId,
        tenantId: context.viewerTenantId,
        deletedAt: null,  // ← 冪等性の鍵
      },
      data: { deletedAt: now, deletedBy: context.actorUserId },
    });

    await tx.knowledge.updateMany({
      where: { projectId, tenantId: context.viewerTenantId, deletedAt: null },
      data: { deletedAt: now, deletedBy: context.actorUserId },
    });

    // ... 他の関連エンティティ

    await tx.project.updateMany({
      where: { id: projectId, tenantId: context.viewerTenantId, deletedAt: null },
      data: { deletedAt: now, deletedBy: context.actorUserId },
    });

    await tx.auditLog.create({
      data: {
        operation: 'project.delete',
        targetId: projectId,
        operatorUserId: context.actorUserId,
        createdAt: now,
      },
    });
  });
}

冪等性の鍵:

ポイント 効果
updateManydeletedAt: null 条件 既に削除済みは更新されない
2 回呼ばれても結果同じ リトライ安全

3. 段階別 transaction でロック時間を最小化

大量データを 1 つの $transaction に入れると、ロック時間が長くなり他の操作をブロックします。これを避けるため、段階別 transaction を採用。

export async function deleteProjectCascade(projectId, context) {
  // 段階 1: 子エンティティ (大量データ) を削除
  await prisma.$transaction(async (tx) => {
    await tx.task.updateMany(...);
    await tx.knowledge.updateMany(...);
    await tx.risk.updateMany(...);
    await tx.issue.updateMany(...);
    await tx.memo.updateMany(...);
    await tx.retrospective.updateMany(...);
  });

  // 段階 2: 中間エンティティを削除
  await prisma.$transaction(async (tx) => {
    await tx.projectMember.updateMany(...);
    await tx.attachment.updateMany(...);
  });

  // 段階 3: Project 本体と AuditLog
  await prisma.$transaction(async (tx) => {
    await tx.project.updateMany(...);
    await tx.auditLog.create(...);
  });
}

各段階は独立して冪等。1 段階あたりのロック時間が短く、他の操作への影響が小さい


4. 段階分けの判断基準

観点 判断
データ量が多い (1000 件超) 別段階に
ロック対象が重複しない 別段階に
整合性が必要な範囲 同段階に
段階
Tasks / Knowledges / Risks (大量 + 互いに参照なし) 段階 1
ProjectMember / Attachment (中量 + Project 参照) 段階 2
Project 本体 + AuditLog (整合性必須) 段階 3

各段階の中では $transaction で原子性を保証。段階間は「1 段階完了して次へ」の流れで、途中失敗でもリトライ可能


5. リトライの仕方

async function deleteProjectWithRetry(projectId, context, maxRetries = 3) {
  for (let attempt = 0; attempt < maxRetries; attempt++) {
    try {
      await deleteProjectCascade(projectId, context);
      return;  // 成功
    } catch (err) {
      if (attempt === maxRetries - 1) throw err;
      await sleep(Math.pow(2, attempt) * 1000);  // exponential backoff
    }
  }
}

冪等なので、何度呼んでも安全。各段階の中で既に削除された項目は触らないため、リトライで二重削除になりません。


6. 削除ジョブを非同期化する案 (将来)

現状は同期削除 (API リクエストが完了するまで待つ)。将来的に、削除を 非同期ジョブ化 する案を検討しています。

// API Route
export async function DELETE(req: NextRequest, { params }) {
  await jobQueue.enqueue('delete-project', { projectId: params.projectId });
  return NextResponse.json({ status: 'queued' }, { status: 202 });
}

// Background worker
jobQueue.process('delete-project', async (job) => {
  await deleteProjectCascade(job.data.projectId, ...);
});
メリット デメリット
ユーザの体感速度が向上 ジョブキューの運用コスト
大規模プロジェクトでもタイムアウトしない 「いつ削除されるか」が見えない

現状のテナント規模では同期削除で十分。規模拡大時に再評価します。


7. 顧客削除のカスケード

顧客 (Customer) を削除すると、その顧客に紐づく全プロジェクトもカスケード削除。

export async function deleteCustomerCascade(
  customerId: string,
  context,
) {
  const projects = await prisma.project.findMany({
    where: { customerId, deletedAt: null },
  });

  // 各プロジェクトをカスケード削除
  for (const project of projects) {
    await deleteProjectCascade(project.id, context);
  }

  // 顧客本体を削除
  await prisma.customer.updateMany({
    where: { id: customerId, deletedAt: null },
    data: { deletedAt: new Date(), deletedBy: context.actorUserId },
  });
}

プロジェクトごとに段階別 transaction が走るため、トータルの実行時間はかかりますが、各段階のロック時間は短い


8. 監査ログは消さない

カスケード削除しても、AuditLog論理削除しません

理由 内容
監査要件 ISMS / SOC2 で操作履歴は永続保持
追跡性 「過去にこのプロジェクトに何があったか」を後から追える
証跡 削除自体も AuditLog に記録

ルール:

テーブル種別 削除可否
履歴系 (AuditLog, ApiCallLog, DriftAuditLog, RoleChangeLog) append-only
業務エンティティ系 論理削除可

おわりに

設計判断 効果
冪等な updateMany (deletedAt: null 条件) リトライ安全
段階別 transaction ロック時間最小化
監査ログは論理削除しない 監査要件対応
super_admin が手動復旧可 誤削除リカバリ

カスケード削除は地味だが、サービスの信頼性に直結する設計。冪等性を最初から組み込むことで、長期運用時のトラブルを減らせます。

本記事のカスケード設計は、運用中の SaaS 「たすきば Knowledge Relay」 で実装しています。
👉 たすきば Knowledge Relay — 公式プロダクトページ

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?