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:
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user