// Job-id deduplication. Recap mints a UUID per summarize job (the // transcribe + analyze pair) and sends it in X-Recap-Job-Id on every // relay call. The first call with a given (install_id, job_id) tuple // reserves a credit; subsequent calls with the same tuple are free // until the job_id expires (1 hour). // // Stored in-memory only — not persisted across restarts because (a) // a restart breaks all in-flight Recap streams anyway and (b) the // worst-case outcome of a "lost reservation" is the user being // charged for a single retry, which is acceptable. const JOB_TTL_MS = 60 * 60 * 1000; // 1 hour // Map const jobs = new Map(); function key(installId, jobId) { return `${installId}|${jobId}`; } // On a new request: returns { charged: true } if this is the first call // for the job (caller must commit a credit), or { charged: false, // backend, tier } if it's a retry/follow-up. export function lookupJob(installId, jobId) { if (!installId || !jobId) return null; pruneExpired(); const k = key(installId, jobId); const existing = jobs.get(k); if (existing && !existing.refunded) return existing; return null; } // Mark a job as having been charged. Idempotent — second call for the // same (install_id, job_id) is a no-op. export function markJobCharged(installId, jobId, { backend, tier }) { if (!installId || !jobId) return; pruneExpired(); const k = key(installId, jobId); if (jobs.has(k) && !jobs.get(k).refunded) return; jobs.set(k, { backend, tier, charged_at: Date.now(), refunded: false, }); } // Refund a previously charged credit for a failed job. Future calls // with the same job_id will be treated as new (since the reservation // is no longer valid). export function refundJob(installId, jobId) { if (!installId || !jobId) return; const k = key(installId, jobId); const existing = jobs.get(k); if (existing) existing.refunded = true; } function pruneExpired() { const cutoff = Date.now() - JOB_TTL_MS; for (const [k, v] of jobs) { if (v.charged_at < cutoff) jobs.delete(k); } } export function snapshotJobs() { pruneExpired(); return Array.from(jobs.entries()).map(([k, v]) => ({ key: k, ...v })); }