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 <noreply@anthropic.com>
This commit is contained in:
Misaka_Company
2026-06-18 11:05:05 +08:00
parent dca5335f9a
commit e91bc90d0f
2 changed files with 57 additions and 13 deletions

View File

@@ -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();
}
}

View File

@@ -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 {