wow
This commit is contained in:
parent
b12ebb725d
commit
5207f5d431
25 changed files with 2932 additions and 332 deletions
|
|
@ -1,11 +1,7 @@
|
|||
import { S3Client } from "bun";
|
||||
import {
|
||||
HeadObjectCommand,
|
||||
PutObjectCommand,
|
||||
S3Client as AwsS3Client,
|
||||
} from "@aws-sdk/client-s3";
|
||||
import config from "@/../config.toml";
|
||||
|
||||
// Bun.S3Client 단일화
|
||||
const client = new S3Client({
|
||||
accessKeyId: config.s3.access_key,
|
||||
secretAccessKey: config.s3.secret_key,
|
||||
|
|
@ -13,108 +9,58 @@ const client = new S3Client({
|
|||
endpoint: config.s3.endpoint,
|
||||
});
|
||||
|
||||
const awsClient = new AwsS3Client({
|
||||
region: "auto",
|
||||
endpoint: config.s3.endpoint,
|
||||
forcePathStyle: true,
|
||||
credentials: {
|
||||
accessKeyId: config.s3.access_key,
|
||||
secretAccessKey: config.s3.secret_key,
|
||||
},
|
||||
});
|
||||
async function warmupS3() {
|
||||
try {
|
||||
await client.exists(`__warmup_${Date.now()}__`);
|
||||
} catch (e) {
|
||||
}
|
||||
}
|
||||
|
||||
function makeS3FileName(authorId: string, tweetId: string, mediaUrl: string, index: number) {
|
||||
const rawName = mediaUrl.split("/").pop() || `media_${Date.now()}_${index}`;
|
||||
const withoutQuery = rawName.split("?")[0]?.split("#")[0] || `media_${Date.now()}_${index}`;
|
||||
const safeName = withoutQuery.replace(/[^a-zA-Z0-9._-]/g, "_");
|
||||
return `twitter/${authorId}/${tweetId}/${safeName || `media_${Date.now()}_${index}`}`;
|
||||
return `twitter/${authorId}/${tweetId}/${safeName}`;
|
||||
}
|
||||
|
||||
async function uploadToS3(fileName: string, mediaUrl: string, maxRetry = 3) {
|
||||
async function existsInS3(key: string) {
|
||||
try {
|
||||
return await client.exists(key);
|
||||
} catch {
|
||||
try {
|
||||
await awsClient.send(new HeadObjectCommand({
|
||||
Bucket: config.s3.bucket,
|
||||
Key: key,
|
||||
}));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function writeToS3(key: string, body: Uint8Array, mediaType?: string | null) {
|
||||
try {
|
||||
await client.write(key, body);
|
||||
return;
|
||||
} catch (bunWriteError) {
|
||||
console.warn(`[S3 bun write failed, fallback to aws-sdk] key=${key}`, bunWriteError);
|
||||
await awsClient.send(new PutObjectCommand({
|
||||
Bucket: config.s3.bucket,
|
||||
Key: key,
|
||||
Body: body,
|
||||
ContentType: mediaType ?? undefined,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
async function recoverByPollingExists(reason: string) {
|
||||
for (let probe = 1; probe <= 4; probe++) {
|
||||
await Bun.sleep(probe * 600);
|
||||
try {
|
||||
if (await existsInS3(fileName)) {
|
||||
console.warn(`[S3 upload recovered-${reason}] key=${fileName} probe=${probe}`);
|
||||
return true;
|
||||
}
|
||||
} catch (existsError) {
|
||||
console.error(`[S3 exists probe failed] key=${fileName} probe=${probe}`, existsError);
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
let lastError: unknown;
|
||||
|
||||
// 1. 요청 시작 전 예열 수행
|
||||
await warmupS3();
|
||||
|
||||
for (let attempt = 1; attempt <= maxRetry; attempt++) {
|
||||
try {
|
||||
const response = await fetch(mediaUrl);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch media from ${mediaUrl}: ${response.status} ${response.statusText}`);
|
||||
throw new Error(`Fetch failed: ${response.status}`);
|
||||
}
|
||||
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
const buffer = Buffer.from(arrayBuffer);
|
||||
await writeToS3(fileName, buffer, response.headers.get("content-type"));
|
||||
return;
|
||||
const buffer = await response.arrayBuffer();
|
||||
|
||||
// 2. Bun S3 Client만 사용하여 쓰기
|
||||
// client.write는 내부적으로 효율적인 스트리밍/버퍼 처리를 수행합니다.
|
||||
await client.write(fileName, buffer, {
|
||||
type: response.headers.get("content-type") || "application/octet-stream",
|
||||
});
|
||||
|
||||
return; // 성공 시 리턴
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
console.error(`[S3 upload attempt ${attempt}/${maxRetry}] key=${fileName} url=${mediaUrl}`, error);
|
||||
|
||||
const errorCode =
|
||||
typeof error === "object" && error !== null && "code" in error
|
||||
? String((error as { code?: unknown }).code)
|
||||
: "";
|
||||
|
||||
// Some S3 providers return UnknownError even when the object is eventually persisted.
|
||||
if (errorCode === "UnknownError") {
|
||||
if (await recoverByPollingExists("unknown")) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
console.error(`[S3 upload attempt ${attempt}/${maxRetry}] key=${fileName}`, error);
|
||||
|
||||
// UnknownError 발생 시 잠시 대기 후 재시도 (지수 백오프)
|
||||
if (attempt < maxRetry) {
|
||||
await Bun.sleep(attempt * 800);
|
||||
await Bun.sleep(attempt * 1000);
|
||||
// 재시도 전 다시 한번 예열 시도 가능
|
||||
await warmupS3();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Final guard: do one last exists check before surfacing failure.
|
||||
if (await recoverByPollingExists("final")) {
|
||||
// 최종 실패 전 마지막 확인 (이미 올라갔을 수도 있음)
|
||||
if (await client.exists(fileName)) {
|
||||
console.warn(`[S3 upload recovered] key=${fileName} was found after error.`);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue