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:
Claude (owner)
2026-06-01 00:07:42 +09:00
parent b0c15f49a4
commit 36c9899040

View File

@@ -185,11 +185,62 @@ export interface DownloadJob {
const jobs = new Map<string, 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> { async function persistJob(job: DownloadJob): Promise<void> {
await fs.mkdir(jobsDir, { recursive: true }) await fs.mkdir(jobsDir, { recursive: true })
await fs.writeFile(path.join(jobsDir, `${job.id}.json`), JSON.stringify(job, null, 2)) 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 { export function getJob(id: string): DownloadJob | null {
return jobs.get(id) ?? null return jobs.get(id) ?? null
} }
@@ -206,11 +257,16 @@ export interface StartDownloadOpts {
videoId?: string videoId?: string
} }
/** 백그라운드 yt-dlp 다운로드를 시작. videoId 도 함께 생성/저장한다. */ /**
* 백그라운드 yt-dlp 다운로드를 큐잉. videoId 도 함께 생성/저장한다.
* 실제 실행은 동시성 한도(MAX_CONCURRENCY) 안에서 pump() 가 결정한다.
*/
export async function startYoutubeDownload(opts: StartDownloadOpts): Promise<DownloadJob> { export async function startYoutubeDownload(opts: StartDownloadOpts): Promise<DownloadJob> {
const folder = getFolder(opts.folderId) const folder = getFolder(opts.folderId)
if (!folder) throw new Error('폴더를 찾을 수 없습니다.') if (!folder) throw new Error('폴더를 찾을 수 없습니다.')
const bin = getYtDlpPath() // 없으면 즉시 던짐 // yt-dlp 없으면 큐잉도 안 한다. 100건 등록 후 전부 error 로 깨지는 것보다
// 즉시 endpoint 가 503 으로 거절하는 편이 낫다.
getYtDlpPath()
const video = await createVideo({ const video = await createVideo({
id: opts.videoId, id: opts.videoId,
@@ -237,12 +293,9 @@ export async function startYoutubeDownload(opts: StartDownloadOpts): Promise<Dow
} }
jobs.set(job.id, job) jobs.set(job.id, job)
void persistJob(job) void persistJob(job)
setImmediate(() => runJob(job, bin).catch((err) => { // 같은 tick 에서 여러 startYoutubeDownload 가 연달아 호출될 수 있으니
job.status = 'error' // pump 는 다음 tick 에 한 번만 돌아도 충분 (반복 호출이 안전하긴 함).
job.error = err instanceof Error ? err.message : String(err) setImmediate(pump)
job.finishedAt = new Date().toISOString()
void persistJob(job)
}))
return job return job
} }