From 80ef29f7873e2a93fa80440eaa641e43ab660874 Mon Sep 17 00:00:00 2001 From: yusufipk Date: Thu, 30 Jul 2026 19:26:06 +0700 Subject: [PATCH] fix(scripts): tell an empty cleanup scan apart from an unreachable one The cleanup printed one number for expired owners, the count of workspaces it found, so zero meant either that nobody had passed the grace period or that everyone who had owns nothing. The first is normal and the second means media is held alive by rows the cleanup cannot reach, and telling them apart took a hand-written query against production. Both counts are reported now. Bunny and R2 results were also discarded. The workspace row is deleted first, so a refused storage delete leaves media that nothing points at, and nothing recorded that it happened. logCleanupWarnings already exists for this and is now called with the per-workspace result. --- scripts/bunny-orphan-cleanup.ts | 1 + scripts/expired-billing-cleanup.ts | 68 ++++++++++++++++++------------ scripts/r2-orphan-cleanup.ts | 1 + 3 files changed, 44 insertions(+), 26 deletions(-) diff --git a/scripts/bunny-orphan-cleanup.ts b/scripts/bunny-orphan-cleanup.ts index 0a44248..8874482 100644 --- a/scripts/bunny-orphan-cleanup.ts +++ b/scripts/bunny-orphan-cleanup.ts @@ -319,6 +319,7 @@ async function main() { console.log(`[bunny-orphan-cleanup] Grace period: ${graceHours}h`); const expiredBillingCleanup = await cleanupExpiredBillingWorkspaces({ dryRun }); + console.log(`[bunny-orphan-cleanup] Expired owners past grace: ${expiredBillingCleanup.owners}`); console.log( `[bunny-orphan-cleanup] Expired owner workspaces scanned: ${expiredBillingCleanup.scanned}` ); diff --git a/scripts/expired-billing-cleanup.ts b/scripts/expired-billing-cleanup.ts index a3ce1e6..29efdac 100644 --- a/scripts/expired-billing-cleanup.ts +++ b/scripts/expired-billing-cleanup.ts @@ -2,6 +2,7 @@ import { db } from '../lib/db'; import { buildExpiredBillingWhereInput } from '../lib/billing'; import { collectWorkspaceMediaUrls, deleteMediaFilesBestEffort } from '../lib/r2-cleanup'; import { cleanupBunnyStreamVideosBestEffort } from '../lib/bunny-stream-cleanup'; +import { logCleanupWarnings } from '../lib/cleanup-warnings'; type ExpiredWorkspaceTarget = { id: string; @@ -9,46 +10,58 @@ type ExpiredWorkspaceTarget = { ownerEmail: string | null; }; -async function getExpiredWorkspaceTargets(): Promise { +/** + * The owners past their grace period, and the workspaces they own. + * + * Both counts are reported, because they answer different questions and a single number + * conflated them: zero workspaces can mean nobody expired, or that everyone who expired owns + * nothing. The first is normal, the second means media is being kept alive by rows the + * cleanup cannot reach, and telling them apart used to require a hand-written query. + */ +async function getExpiredWorkspaceTargets(): Promise<{ + owners: number; + workspaces: ExpiredWorkspaceTarget[]; +}> { const expiredOwners = await db.user.findMany({ where: buildExpiredBillingWhereInput(), select: { id: true }, }); if (expiredOwners.length === 0) { - return []; + return { owners: 0, workspaces: [] }; } - return db.workspace - .findMany({ - where: { - ownerId: { in: expiredOwners.map((owner) => owner.id) }, - }, - select: { - id: true, - ownerId: true, - owner: { - select: { - email: true, - }, + const workspaces = await db.workspace.findMany({ + where: { + ownerId: { in: expiredOwners.map((owner) => owner.id) }, + }, + select: { + id: true, + ownerId: true, + owner: { + select: { + email: true, }, }, - }) - .then((workspaces) => - workspaces.map((workspace) => ({ - id: workspace.id, - ownerId: workspace.ownerId, - ownerEmail: workspace.owner.email, - })) - ); + }, + }); + + return { + owners: expiredOwners.length, + workspaces: workspaces.map((workspace) => ({ + id: workspace.id, + ownerId: workspace.ownerId, + ownerEmail: workspace.owner.email, + })), + }; } export async function cleanupExpiredBillingWorkspaces(options?: { dryRun?: boolean }) { const dryRun = options?.dryRun ?? false; - const workspaces = await getExpiredWorkspaceTargets(); + const { owners, workspaces } = await getExpiredWorkspaceTargets(); if (workspaces.length === 0) { - return { scanned: 0, deleted: 0 }; + return { owners, scanned: 0, deleted: 0 }; } let deleted = 0; @@ -102,12 +115,15 @@ export async function cleanupExpiredBillingWorkspaces(options?: { dryRun?: boole ]; await db.workspace.delete({ where: { id: workspace.id } }); - await Promise.all([ + const [bunny, r2] = await Promise.all([ cleanupBunnyStreamVideosBestEffort(bunnyRefs), deleteMediaFilesBestEffort(mediaUrls), ]); + // The rows are already gone, so a refused delete leaves media nothing points at. The + // orphan sweep in the calling script picks those up, but only a log says it happened. + logCleanupWarnings({ entityType: 'workspace', entityId: workspace.id }, { bunny, r2 }); deleted += 1; } - return { scanned: workspaces.length, deleted }; + return { owners, scanned: workspaces.length, deleted }; } diff --git a/scripts/r2-orphan-cleanup.ts b/scripts/r2-orphan-cleanup.ts index 99dde3b..e35bc98 100644 --- a/scripts/r2-orphan-cleanup.ts +++ b/scripts/r2-orphan-cleanup.ts @@ -209,6 +209,7 @@ async function main() { console.log(`[r2-orphan-cleanup] Starting (${dryRun ? 'dry-run' : 'delete mode'})`); const expiredBillingCleanup = await cleanupExpiredBillingWorkspaces({ dryRun }); + console.log(`[r2-orphan-cleanup] Expired owners past grace: ${expiredBillingCleanup.owners}`); console.log( `[r2-orphan-cleanup] Expired owner workspaces scanned: ${expiredBillingCleanup.scanned}` );