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>()
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user