Files
atlas-seller-kit/apps/worker/src/media-upload-cleanup.ts
T
2026-09-04 15:58:53 +08:00

108 lines
3.8 KiB
TypeScript

import { DeleteObjectCommand, type S3Client } from '@aws-sdk/client-s3';
import type { Database } from '@atlas/database';
type CleanupCandidate = {
id: string;
bucket: string;
object_key: string;
};
export class MediaUploadCleanup {
private running: Promise<void> | null = null;
public constructor(
private readonly database: Database,
private readonly s3: S3Client,
) {}
/**
* 上传 URL 到期并不代表浏览器一定上传过对象;DeleteObject 对不存在对象也安全,
* 所以终态意图统一尝试删除。只在删除成功后写 object_cleaned_at,失败下轮重试。
*/
public async run() {
if (this.running) return this.running;
this.running = this.runOnce().finally(() => {
this.running = null;
});
return this.running;
}
private async runOnce() {
const workspaces = await this.database.query<{ id: string }>(
'SELECT id FROM atlas.workspaces',
);
let expired = 0;
let cleaned = 0;
for (const workspace of workspaces.rows) {
expired += await this.expirePending(workspace.id);
const candidates = await this.claimCandidates(workspace.id);
for (const candidate of candidates) {
try {
await this.s3.send(
new DeleteObjectCommand({
Bucket: candidate.bucket,
Key: candidate.object_key,
}),
);
await this.markCleaned(workspace.id, candidate.id);
cleaned += 1;
} catch (error) {
console.warn(
'media_upload_cleanup_failed',
JSON.stringify({
workspace_id: workspace.id,
upload_id: candidate.id,
reason:
error instanceof Error
? error.message
: 'unknown_error',
}),
);
}
}
}
if (expired || cleaned) {
console.info(
'media_upload_cleanup_completed',
JSON.stringify({ expired, cleaned }),
);
}
}
private async expirePending(workspaceId: string): Promise<number> {
return this.database.workspaceTransaction(workspaceId, async (tx) => {
const result = await tx.query(
`UPDATE atlas.media_upload_intents
SET status = 'expired', error_code = 'UPLOAD_EXPIRED',
error_message = '上传意图已过期', completed_at = now()
WHERE status = 'pending' AND expires_at <= now()`,
);
return result.rowCount ?? 0;
});
}
private async claimCandidates(workspaceId: string) {
return this.database.workspaceTransaction(workspaceId, async (tx) => {
const candidates = await tx.query<CleanupCandidate>(
`SELECT id, bucket, object_key
FROM atlas.media_upload_intents
WHERE status IN ('failed', 'expired') AND object_cleaned_at IS NULL
ORDER BY created_at
LIMIT 50`,
);
return candidates.rows;
});
}
private async markCleaned(workspaceId: string, intentId: string) {
await this.database.workspaceTransaction(workspaceId, (tx) =>
tx.query(
`UPDATE atlas.media_upload_intents SET object_cleaned_at = now()
WHERE id = $1 AND status IN ('failed', 'expired')
AND object_cleaned_at IS NULL`,
[intentId],
),
);
}
}