import { createHash } from 'node:crypto'; import { ApiError } from '../habits/service'; export class RenderBusyError extends ApiError { constructor() { super(429, 'Image rendering is busy. Try again shortly.'); } } type Job = { run: () => Promise; resolve: (image: Uint8Array) => void; reject: (reason: unknown) => void }; /** One process-wide admission boundary for every server canvas renderer. */ export function createRenderQueue({ concurrency = 2, maxPending = 16, maxPerUser = 2 } = {}) { const pending = new Map>(); const owners = new Map(); const waiting: Job[] = []; let active = 0; function pump() { while (active < concurrency && waiting.length) { const job = waiting.shift()!; active++; // Yield before drawing so a burst of callers cannot monopolize this turn. setTimeout(() => { void Promise.resolve().then(job.run).then(job.resolve, job.reject).finally(() => { active--; pump(); }); }, 0); } } return (userId: string, key: string, run: Job['run']): Promise => { const scopedKey = JSON.stringify([userId, key]); const existing = pending.get(scopedKey); if (existing) return existing; if (pending.size >= maxPending || (owners.get(userId) ?? 0) >= maxPerUser) return Promise.reject(new RenderBusyError()); owners.set(userId, (owners.get(userId) ?? 0) + 1); const work = new Promise((resolve, reject) => { waiting.push({ run, resolve, reject }); }).finally(() => { pending.delete(scopedKey); const count = owners.get(userId)! - 1; if (count) owners.set(userId, count); else owners.delete(userId); }); pending.set(scopedKey, work); pump(); return work; }; } const schedule = createRenderQueue(); export function renderImage(kind: string, userId: string, data: unknown, theme: string, run: Job['run']) { const hash = createHash('sha256').update(JSON.stringify([kind, data, theme])).digest('hex'); return schedule(userId, hash, run); }