From e91bc90d0f091a65515bcc6acd516d2bf3bfdce2 Mon Sep 17 00:00:00 2001 From: Misaka_Company Date: Thu, 18 Jun 2026 11:05:05 +0800 Subject: [PATCH] Serialize exports to prevent OOM spikes from stacking /api/export-excel now holds a per-process mutex: concurrent callers get an instant 429 instead of stacking multiple ~1.3GB export spikes that exceed Node's default ~2GB heap. Frontend handleExport treats 429 as 'another export running' and auto-retries (3s backoff, up to 7 attempts). A 90s timeout reclaims a stuck lock. Heap raise + orphan cleanup applied at deploy. Co-Authored-By: Claude --- src/app/api/export-excel/route.ts | 34 +++++++++++++++++++++++++++++ src/app/page.tsx | 36 ++++++++++++++++++++----------- 2 files changed, 57 insertions(+), 13 deletions(-) diff --git a/src/app/api/export-excel/route.ts b/src/app/api/export-excel/route.ts index c464c7e..6474a34 100644 --- a/src/app/api/export-excel/route.ts +++ b/src/app/api/export-excel/route.ts @@ -41,7 +41,40 @@ const DATE_COLUMNS = new Set([ "烘洗", ]); +/* + * Serialize exports. A single export of the full dataset (~33k rows) peaks at + * ~1.3GB heap; letting two run at once would exceed Node's default ~2GB heap + * and OOM the server. All /api/export-excel requests are handled by the one + * Next server process (the listener on the port), so a module-level flag + * correctly serializes concurrent requests: the 2nd+ callers get an instant + * 429 and the frontend retries. Memory peak is capped at one export at a time. + */ +let exportLocked = false; +let exportLockedAt = 0; +const EXPORT_LOCK_TIMEOUT_MS = 90_000; // safety: reclaim a stuck lock after 90s + +function acquireExportLock(): boolean { + if (exportLocked && Date.now() - exportLockedAt > EXPORT_LOCK_TIMEOUT_MS) { + console.warn("Export lock reclaimed after timeout"); + exportLocked = false; + } + if (exportLocked) return false; + exportLocked = true; + exportLockedAt = Date.now(); + return true; +} + +function releaseExportLock() { + exportLocked = false; +} + export async function GET() { + if (!acquireExportLock()) { + return NextResponse.json( + { error: "正在导出,请稍候" }, + { status: 429, headers: { "Retry-After": "3" } } + ); + } let pool: sql.ConnectionPool | undefined; try { pool = await sql.connect(dbConfig); @@ -136,5 +169,6 @@ export async function GET() { if (pool) { await pool.close(); } + releaseExportLock(); } } diff --git a/src/app/page.tsx b/src/app/page.tsx index 365c3f6..3bc2a0f 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -542,22 +542,32 @@ export default function Home() { const handleExport = useCallback(async () => { setExporting(true); + setError(null); try { - const res = await fetch("/api/export-excel"); - if (!res.ok) { - const json = await res.json().catch(() => null); - setError(json?.error || "导出失败"); + const MAX_ATTEMPTS = 7; // 429 = 另有人在导出,退避后自动重试 + for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { + const res = await fetch("/api/export-excel"); + if (res.status === 429) { + await new Promise((r) => setTimeout(r, 3000)); + continue; + } + if (!res.ok) { + const json = await res.json().catch(() => null); + setError(json?.error || "导出失败"); + return; + } + const blob = await res.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = "压力表合同生产数据.xlsx"; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); return; } - const blob = await res.blob(); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = "压力表合同生产数据.xlsx"; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); + setError("导出繁忙,请稍后重试"); } catch { setError("导出请求失败"); } finally {