fix(playlist): cap concurrent downloads (P1 review fix)
플레이리스트 일괄 등록 시 모든 항목이 즉시 yt-dlp+ffmpeg 를 띄워 서버를 박살내던 문제 수정. `startYoutubeDownload` 는 더 이상 직접 runJob 을 setImmediate 로 띄우지 않고, `queued` 상태로만 등록한 뒤 `pump()` 가 활성 잡 수를 보고 슬롯이 비었을 때만 launchJob 한다. 완료/실패 시 `finally` 에서 카운터를 줄이고 다음 큐를 펌핑. 기본 동시 실행 한도는 2 (네트워크 다운로드 + CPU 후처리가 한쌍은 같이 굴러갈 만한 선). `MAX_DOWNLOAD_CONCURRENCY=N` 환경변수로 조정. 스모크 (yt-dlp 스텁 + 3s sleep): - MAX=1, 3건 등록 → t+0.5s: 1 downloading + 2 queued → t+4s: 1 done + 1 downloading + 1 queued → t+10s: 3 done - MAX=2 (기본), 4건 등록 → t+0.5s: 2 downloading + 2 queued → t+8s: 4 done Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -185,11 +185,62 @@ export interface DownloadJob {
|
||||
|
||||
const jobs = new Map<string, DownloadJob>()
|
||||
|
||||
// 동시 실행 상한. 플레이리스트 일괄 등록 시 yt-dlp+ffmpeg 가 N개 동시에 떠
|
||||
// 서버가 터지는 것을 막는다. 기본 2 — 네트워크-바운드 다운로드와 CPU-바운드
|
||||
// 60fps 후처리가 한 번에 한쌍 정도는 같이 진행될 수 있게.
|
||||
const MAX_CONCURRENCY = (() => {
|
||||
const raw = Number(process.env.MAX_DOWNLOAD_CONCURRENCY)
|
||||
if (Number.isFinite(raw) && raw >= 1) return Math.floor(raw)
|
||||
return 2
|
||||
})()
|
||||
let activeJobCount = 0
|
||||
|
||||
async function persistJob(job: DownloadJob): Promise<void> {
|
||||
await fs.mkdir(jobsDir, { recursive: true })
|
||||
await fs.writeFile(path.join(jobsDir, `${job.id}.json`), JSON.stringify(job, null, 2))
|
||||
}
|
||||
|
||||
/**
|
||||
* 대기 큐를 끌어올린다.
|
||||
* - 동시 실행 슬롯이 남는 동안, 가장 먼저 등록된 `queued` 잡부터 launchJob 호출.
|
||||
* - launchJob 의 finally 에서 다시 호출되어 새 슬롯을 채운다.
|
||||
*/
|
||||
function pump(): void {
|
||||
if (activeJobCount >= MAX_CONCURRENCY) return
|
||||
for (const job of jobs.values()) {
|
||||
if (activeJobCount >= MAX_CONCURRENCY) return
|
||||
if (job.status !== 'queued') continue
|
||||
let bin: string
|
||||
try {
|
||||
bin = getYtDlpPath()
|
||||
} catch (err) {
|
||||
// yt-dlp 가 중간에 사라진 경우. 이 잡만 실패시키고 다음 잡으로 진행 (다음도 같은 사유로 실패하겠지만).
|
||||
job.status = 'error'
|
||||
job.error = err instanceof Error ? err.message : String(err)
|
||||
job.finishedAt = new Date().toISOString()
|
||||
void persistJob(job)
|
||||
continue
|
||||
}
|
||||
launchJob(job, bin)
|
||||
}
|
||||
}
|
||||
|
||||
function launchJob(job: DownloadJob, bin: string): void {
|
||||
activeJobCount += 1
|
||||
runJob(job, bin)
|
||||
.catch((err) => {
|
||||
job.status = 'error'
|
||||
job.error = err instanceof Error ? err.message : String(err)
|
||||
job.finishedAt = new Date().toISOString()
|
||||
void persistJob(job)
|
||||
})
|
||||
.finally(() => {
|
||||
activeJobCount -= 1
|
||||
// 비동기 완료 시점에 다음 큐 슬롯을 즉시 채운다.
|
||||
pump()
|
||||
})
|
||||
}
|
||||
|
||||
export function getJob(id: string): DownloadJob | null {
|
||||
return jobs.get(id) ?? null
|
||||
}
|
||||
@@ -206,11 +257,16 @@ export interface StartDownloadOpts {
|
||||
videoId?: string
|
||||
}
|
||||
|
||||
/** 백그라운드 yt-dlp 다운로드를 시작. videoId 도 함께 생성/저장한다. */
|
||||
/**
|
||||
* 백그라운드 yt-dlp 다운로드를 큐잉. videoId 도 함께 생성/저장한다.
|
||||
* 실제 실행은 동시성 한도(MAX_CONCURRENCY) 안에서 pump() 가 결정한다.
|
||||
*/
|
||||
export async function startYoutubeDownload(opts: StartDownloadOpts): Promise<DownloadJob> {
|
||||
const folder = getFolder(opts.folderId)
|
||||
if (!folder) throw new Error('폴더를 찾을 수 없습니다.')
|
||||
const bin = getYtDlpPath() // 없으면 즉시 던짐
|
||||
// yt-dlp 없으면 큐잉도 안 한다. 100건 등록 후 전부 error 로 깨지는 것보다
|
||||
// 즉시 endpoint 가 503 으로 거절하는 편이 낫다.
|
||||
getYtDlpPath()
|
||||
|
||||
const video = await createVideo({
|
||||
id: opts.videoId,
|
||||
@@ -237,12 +293,9 @@ export async function startYoutubeDownload(opts: StartDownloadOpts): Promise<Dow
|
||||
}
|
||||
jobs.set(job.id, job)
|
||||
void persistJob(job)
|
||||
setImmediate(() => runJob(job, bin).catch((err) => {
|
||||
job.status = 'error'
|
||||
job.error = err instanceof Error ? err.message : String(err)
|
||||
job.finishedAt = new Date().toISOString()
|
||||
void persistJob(job)
|
||||
}))
|
||||
// 같은 tick 에서 여러 startYoutubeDownload 가 연달아 호출될 수 있으니
|
||||
// pump 는 다음 tick 에 한 번만 돌아도 충분 (반복 호출이 안전하긴 함).
|
||||
setImmediate(pump)
|
||||
return job
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user