Skip to content
Back to posts
On this page
~/posts/backend

Two-Phase Deletion Pattern

A safe deletion pattern for systems without rollback capability where external

API calls must succeed before data is permanently removed.

The Problem

When deleting data that must also be deleted from an external service (e.g., Google Calendar), immediate hard-delete is risky:

// ❌ RISKY: No recovery if Google API fails
async deleteBlock(id: number) {
  await this.blockRepo.delete(id);        // Gone from DB
  await this.googleApi.deleteEvent(id);   // What if this fails?
}

If the Google API call fails, the data is already gone from the database.

The Solution: Two Phases

Phase 1: Soft-Delete (Service Layer)

Mark records as “tentatively deleted” without removing them:

async deleteBlock(id: number) {
  // Soft-delete: set deletedAt, keep record
  await this.blockRepo.softRemove(block);

  // Queue the external API call
  await this.eventQueue.add('delete', { blockId: id });
}

Phase 2: Hard-Delete (Queue Processor)

After external API confirms, permanently remove:

async processDelete(job: Job) {
  const block = await this.blockRepo.findOne({
    where: { id: job.data.blockId },
    withDeleted: true,  // Include soft-deleted
  });

  // Confirm with external API
  await this.googleApi.deleteEvent(block.gcalId);

  // Now safe to hard-delete
  await this.blockRepo.delete(block.id);
}

Phase 3: Safety Net (Sync Layer)

Cleanup orphans that queue processor missed:

async sync() {
  // Detect and clean orphaned records
  const orphans = await this.findOrphans();
  for (const orphan of orphans) {
    this.logger.warn('Orphan detected', { id: orphan.id });
    await this.blockRepo.delete(orphan.id);
  }
}

Flow Diagram

User Request → Service Layer (soft-delete) → Queue Job

                                            Queue Processor

                                            Google API OK?
                                            /           
                                          Yes            No
                                           ↓              ↓
                                     Hard-delete    Retry/Alert

                                       Sync Layer (safety net)

When to Use

ScenarioUse Two-Phase?
External API required✅ Yes
Database-only delete❌ No (direct delete)
No rollback mechanism✅ Yes
Critical user data✅ Yes

Key Implementation Details

Soft-Delete vs Status Field

For some entities, soft-delete (deletedAt) hides them from queries. But sometimes you need visibility:

// T blocks need itemStatus=Deleted (visible to client)
// NOT deletedAt (hidden from queries)
if (isTBlock(block)) {
  block.itemStatus = BlockStatus.Deleted;
  // Do NOT set deletedAt - client needs to see cancelled markers
} else {
  await this.blockRepo.softRemove(block);
}

Orphan Detection

Track both parent and source relationships:

// Gap 1: Direct children (originalId)
await this.blockRepo.delete({ originalId: deletedBlockId });

// Gap 2: Divergence chain (recurringEventId in JSON)
await this.blockRepo.delete({
  googleEventData: { recurringEventId: deletedGcalId },
});

Key Lessons

  1. Separate concerns - Service layer marks, queue confirms, sync cleans
  2. Defense in depth - Queue processor + sync layer = double safety
  3. Log orphans - Visibility into missed cleanups
  4. Status vs deletion - Different semantics for different use cases
  5. Backward compatibility - Handle existing bad data incrementally

Comments

Back to posts
enko