この記事は約 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,
},
});
});
}
冪等性の鍵:
| ポイント | 効果 |
|---|---|
updateMany が deletedAt: 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 — 公式プロダクトページ