Files
recap-relay/server/job-credits.js
T
2026-05-11 20:03:27 -05:00

69 lines
2.2 KiB
JavaScript

// 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<install_id|job_id, { backend, tier, charged_at, refunded }>
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 }));
}