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.
This commit is contained in:
yusufipk
2026-07-30 19:26:06 +07:00
parent a3036f1a52
commit 80ef29f787
3 changed files with 44 additions and 26 deletions
+1
View File
@@ -319,6 +319,7 @@ async function main() {
console.log(`[bunny-orphan-cleanup] Grace period: ${graceHours}h`); console.log(`[bunny-orphan-cleanup] Grace period: ${graceHours}h`);
const expiredBillingCleanup = await cleanupExpiredBillingWorkspaces({ dryRun }); const expiredBillingCleanup = await cleanupExpiredBillingWorkspaces({ dryRun });
console.log(`[bunny-orphan-cleanup] Expired owners past grace: ${expiredBillingCleanup.owners}`);
console.log( console.log(
`[bunny-orphan-cleanup] Expired owner workspaces scanned: ${expiredBillingCleanup.scanned}` `[bunny-orphan-cleanup] Expired owner workspaces scanned: ${expiredBillingCleanup.scanned}`
); );
+29 -13
View File
@@ -2,6 +2,7 @@ import { db } from '../lib/db';
import { buildExpiredBillingWhereInput } from '../lib/billing'; import { buildExpiredBillingWhereInput } from '../lib/billing';
import { collectWorkspaceMediaUrls, deleteMediaFilesBestEffort } from '../lib/r2-cleanup'; import { collectWorkspaceMediaUrls, deleteMediaFilesBestEffort } from '../lib/r2-cleanup';
import { cleanupBunnyStreamVideosBestEffort } from '../lib/bunny-stream-cleanup'; import { cleanupBunnyStreamVideosBestEffort } from '../lib/bunny-stream-cleanup';
import { logCleanupWarnings } from '../lib/cleanup-warnings';
type ExpiredWorkspaceTarget = { type ExpiredWorkspaceTarget = {
id: string; id: string;
@@ -9,18 +10,28 @@ type ExpiredWorkspaceTarget = {
ownerEmail: string | null; ownerEmail: string | null;
}; };
async function getExpiredWorkspaceTargets(): Promise<ExpiredWorkspaceTarget[]> { /**
* 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({ const expiredOwners = await db.user.findMany({
where: buildExpiredBillingWhereInput(), where: buildExpiredBillingWhereInput(),
select: { id: true }, select: { id: true },
}); });
if (expiredOwners.length === 0) { if (expiredOwners.length === 0) {
return []; return { owners: 0, workspaces: [] };
} }
return db.workspace const workspaces = await db.workspace.findMany({
.findMany({
where: { where: {
ownerId: { in: expiredOwners.map((owner) => owner.id) }, ownerId: { in: expiredOwners.map((owner) => owner.id) },
}, },
@@ -33,22 +44,24 @@ async function getExpiredWorkspaceTargets(): Promise<ExpiredWorkspaceTarget[]> {
}, },
}, },
}, },
}) });
.then((workspaces) =>
workspaces.map((workspace) => ({ return {
owners: expiredOwners.length,
workspaces: workspaces.map((workspace) => ({
id: workspace.id, id: workspace.id,
ownerId: workspace.ownerId, ownerId: workspace.ownerId,
ownerEmail: workspace.owner.email, ownerEmail: workspace.owner.email,
})) })),
); };
} }
export async function cleanupExpiredBillingWorkspaces(options?: { dryRun?: boolean }) { export async function cleanupExpiredBillingWorkspaces(options?: { dryRun?: boolean }) {
const dryRun = options?.dryRun ?? false; const dryRun = options?.dryRun ?? false;
const workspaces = await getExpiredWorkspaceTargets(); const { owners, workspaces } = await getExpiredWorkspaceTargets();
if (workspaces.length === 0) { if (workspaces.length === 0) {
return { scanned: 0, deleted: 0 }; return { owners, scanned: 0, deleted: 0 };
} }
let 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 db.workspace.delete({ where: { id: workspace.id } });
await Promise.all([ const [bunny, r2] = await Promise.all([
cleanupBunnyStreamVideosBestEffort(bunnyRefs), cleanupBunnyStreamVideosBestEffort(bunnyRefs),
deleteMediaFilesBestEffort(mediaUrls), 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; deleted += 1;
} }
return { scanned: workspaces.length, deleted }; return { owners, scanned: workspaces.length, deleted };
} }
+1
View File
@@ -209,6 +209,7 @@ async function main() {
console.log(`[r2-orphan-cleanup] Starting (${dryRun ? 'dry-run' : 'delete mode'})`); console.log(`[r2-orphan-cleanup] Starting (${dryRun ? 'dry-run' : 'delete mode'})`);
const expiredBillingCleanup = await cleanupExpiredBillingWorkspaces({ dryRun }); const expiredBillingCleanup = await cleanupExpiredBillingWorkspaces({ dryRun });
console.log(`[r2-orphan-cleanup] Expired owners past grace: ${expiredBillingCleanup.owners}`);
console.log( console.log(
`[r2-orphan-cleanup] Expired owner workspaces scanned: ${expiredBillingCleanup.scanned}` `[r2-orphan-cleanup] Expired owner workspaces scanned: ${expiredBillingCleanup.scanned}`
); );